Loadout-Bereich: Granaten und Minen pro Karte festlegen (V18, Undo, auch im Spieler-Modus, gilt für Bots); Tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GkB4i3mQaxfg6aEncovy2Q
This commit is contained in:
slyphax 2026-10-31 01:55:45 +01:00
parent 330365e59b
commit 91c66a0b98
3 changed files with 184 additions and 5 deletions

View file

@ -7,6 +7,8 @@ extends RefCounted
## doc["loadout"] fehlt oder mode "player" = Spieler-Loadout aus der Lobby. ## doc["loadout"] fehlt oder mode "player" = Spieler-Loadout aus der Lobby.
## mode "fixed" = {primary, secondary ("" = keine), attachments:{waffe: "slot=teil;…"}} ## mode "fixed" = {primary, secondary ("" = keine), attachments:{waffe: "slot=teil;…"}}
## — Aufsatz-Text nur mit Abweichungen vom Standard (Draht-Format wie im Client). ## — Aufsatz-Text nur mit Abweichungen vom Standard (Draht-Format wie im Client).
## In beiden Modi optional: grenades:{id: anzahl} und mines:anzahl = Vorrat pro
## Leben für Spieler und Bots; fehlen sie, gilt der Standard des Servers.
## Katalog-Quelle: library/loadout_katalog.json (tools/sync_loadout_katalog.py). ## Katalog-Quelle: library/loadout_katalog.json (tools/sync_loadout_katalog.py).
const PATH := "res://library/loadout_katalog.json" const PATH := "res://library/loadout_katalog.json"
@ -20,6 +22,21 @@ const TEIL_NAMEN := {
"sight_offset": "Schrägvisier (seitlich, Punkt)", "sight_offset": "Schrägvisier (seitlich, Punkt)",
} }
## Granatenarten des Servers (library/grenades.json); die ersten drei stellt das Panel zur Wahl.
const GRANATEN := {
"he1": "Splittergranate",
"blend1": "Blendgranate",
"smoke1": "Rauchgranate",
"he2": "Splittergranate leicht",
"blend2": "Blendgranate kurz",
"smoke2": "Nebelgranate",
"bomb": "Bombe",
}
const GRANATEN_PANEL: Array[String] = ["he1", "blend1", "smoke1"]
const MAX_GRANATEN_JE_ART := 5
const MAX_MINEN := 3 # Spiegel zu MineSystem.MAX_ACTIVE_PER_OWNER
const STANDARD_MINEN := 2 # Spiegel zu MineSystem.START_STOCK
static var _data: Dictionary = {} static var _data: Dictionary = {}
static var _loaded := false static var _loaded := false
@ -135,6 +152,7 @@ static func validate(lo: Variant) -> PackedStringArray:
if mode != MODE_FIXED and mode != MODE_PLAYER: if mode != MODE_FIXED and mode != MODE_PLAYER:
errors.append("V18: loadout.mode muss 'fixed' oder 'player' sein (ist '%s')." % mode) errors.append("V18: loadout.mode muss 'fixed' oder 'player' sein (ist '%s')." % mode)
return errors return errors
errors.append_array(_pruefe_vorrat(d))
if mode == MODE_PLAYER: if mode == MODE_PLAYER:
return errors return errors
var prim := str(d.get("primary", "")).strip_edges().to_lower() var prim := str(d.get("primary", "")).strip_edges().to_lower()
@ -158,3 +176,26 @@ static func validate(lo: Variant) -> PackedStringArray:
if why != "": if why != "":
errors.append("V18: loadout.attachments['%s']: %s." % [w, why]) errors.append("V18: loadout.attachments['%s']: %s." % [w, why])
return errors return errors
static func _pruefe_vorrat(d: Dictionary) -> PackedStringArray:
var errors := PackedStringArray()
if d.has("grenades"):
var g: Variant = d["grenades"]
if not (g is Dictionary):
errors.append("V18: loadout.grenades muss ein Objekt sein.")
else:
for gid in (g as Dictionary).keys():
if not GRANATEN.has(str(gid)):
errors.append("V18: loadout.grenades['%s'] ist keine bekannte Granate." % str(gid))
elif not _ist_anzahl(g[gid], MAX_GRANATEN_JE_ART):
errors.append("V18: loadout.grenades['%s'] muss eine ganze Zahl von 0 bis %d sein." % [str(gid), MAX_GRANATEN_JE_ART])
if d.has("mines") and not _ist_anzahl(d["mines"], MAX_MINEN):
errors.append("V18: loadout.mines muss eine ganze Zahl von 0 bis %d sein." % MAX_MINEN)
return errors
static func _ist_anzahl(v: Variant, max_n: int) -> bool:
if not (v is int or v is float):
return false
return float(v) == floorf(float(v)) and float(v) >= 0.0 and float(v) <= float(max_n)

View file

@ -3,7 +3,8 @@ extends "res://src/ui/panels/editor_panel.gd"
## Spieler ihr eigenes Lobby-Loadout mitbringen oder die Map ein festes Loadout ## Spieler ihr eigenes Lobby-Loadout mitbringen oder die Map ein festes Loadout
## (Primär-/Sekundärwaffe + Aufsätze) vorschreibt. Gespeichert als optionaler ## (Primär-/Sekundärwaffe + Aufsätze) vorschreibt. Gespeichert als optionaler
## Block doc["loadout"] (Regeln: src/svmap/loadout_katalog.gd, V18). Der Server ## Block doc["loadout"] (Regeln: src/svmap/loadout_katalog.gd, V18). Der Server
## setzt ein festes Loadout durch, der Client zeigt es an. ## setzt ein festes Loadout durch, der Client zeigt es an. In beiden Modi lässt
## sich zusätzlich der Granaten-/Minen-Vorrat pro Leben festlegen (auch für Bots).
## ##
## Jede Änderung ist ein Undo-Schritt (History-Block "loadout"; fehlt der Block, ## Jede Änderung ist ein Undo-Schritt (History-Block "loadout"; fehlt der Block,
## ist das Spieler-Loadout gemeint). ## ist das Spieler-Loadout gemeint).
@ -16,6 +17,10 @@ var secondary_option: OptionButton
var fixed_box: VBoxContainer var fixed_box: VBoxContainer
var attach_box: VBoxContainer var attach_box: VBoxContainer
var hint_label: Label var hint_label: Label
var vorrat_check: CheckBox
var vorrat_box: VBoxContainer
var granaten_spins: Dictionary = {} # Granaten-ID -> SpinBox
var minen_spin: SpinBox
var _loading := false var _loading := false
var _ids: Array[String] = [] var _ids: Array[String] = []
@ -60,9 +65,31 @@ func build() -> void:
attach_box.add_theme_constant_override("separation", UIScale.px(6)) attach_box.add_theme_constant_override("separation", UIScale.px(6))
fixed_box.add_child(attach_box) fixed_box.add_child(attach_box)
hint_label = _muted_label("Messer, Minen und Granaten bleiben unverändert.") hint_label = _muted_label("Messer bleiben unverändert.")
fixed_box.add_child(hint_label) fixed_box.add_child(hint_label)
add_child(_header_label("Granaten und Minen"))
vorrat_check = CheckBox.new()
vorrat_check.text = "Anzahl für diese Karte festlegen"
vorrat_check.tooltip_text = "Aus: es gilt der Standard des Servers. An: Spieler und Bots bekommen pro Leben genau diese Anzahl."
vorrat_check.toggled.connect(func(an: bool) -> void:
if not _loading:
set_vorrat_aktiv(an))
add_child(vorrat_check)
vorrat_box = VBoxContainer.new()
vorrat_box.add_theme_constant_override("separation", UIScale.px(6))
add_child(vorrat_box)
for gid in LoadoutKatalog.GRANATEN_PANEL:
var spin := _anzahl_zeile(str(LoadoutKatalog.GRANATEN[gid]), LoadoutKatalog.MAX_GRANATEN_JE_ART)
spin.value_changed.connect(func(v: float) -> void:
if not _loading:
set_granaten(gid, int(v)))
granaten_spins[gid] = spin
minen_spin = _anzahl_zeile("Minen", LoadoutKatalog.MAX_MINEN)
minen_spin.value_changed.connect(func(v: float) -> void:
if not _loading:
set_minen(int(v)))
mode_option.item_selected.connect(func(i: int) -> void: set_fixed(i == 1)) mode_option.item_selected.connect(func(i: int) -> void: set_fixed(i == 1))
primary_option.item_selected.connect(func(i: int) -> void: primary_option.item_selected.connect(func(i: int) -> void:
set_weapons(_ids[i], _secondary_id())) set_weapons(_ids[i], _secondary_id()))
@ -71,6 +98,20 @@ func build() -> void:
refresh_values() refresh_values()
func _anzahl_zeile(titel: String, max_n: int) -> SpinBox:
var row := HBoxContainer.new()
var lab := _field_label(titel)
lab.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(lab)
var spin := SpinBox.new()
spin.min_value = 0
spin.max_value = max_n
spin.step = 1
row.add_child(spin)
vorrat_box.add_child(row)
return spin
# ------------------------------------------------------------- Lesen ------- # ------------------------------------------------------------- Lesen -------
func _primary_id() -> String: func _primary_id() -> String:
@ -97,15 +138,72 @@ func set_fixed(fixed: bool) -> void:
if main.doc.is_empty(): if main.doc.is_empty():
return return
if not fixed: if not fixed:
_commit(null, "Loadout: Spieler-Loadout") _commit(_nur_vorrat(current()), "Loadout: Spieler-Loadout")
return return
var cur := current() var cur := current()
if cur.get("mode", "") == LoadoutKatalog.MODE_FIXED: if cur.get("mode", "") == LoadoutKatalog.MODE_FIXED:
return return
var prim := "m40" if LoadoutKatalog.hat_waffe("m40") else (_ids[0] if not _ids.is_empty() else "") 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 var sec := "pistol1" if LoadoutKatalog.hat_waffe("pistol1") else KEINE
_commit({"mode": LoadoutKatalog.MODE_FIXED, "primary": prim, "secondary": sec, "attachments": {}}, var neu := {"mode": LoadoutKatalog.MODE_FIXED, "primary": prim, "secondary": sec, "attachments": {}}
"Loadout: festes Map-Loadout") var vorrat: Variant = _nur_vorrat(cur)
if vorrat != null:
neu.merge(vorrat as Dictionary, false)
_commit(neu, "Loadout: festes Map-Loadout")
## Spieler-Loadout mit Vorrat bleibt als Block erhalten; ohne Vorrat kein Block.
func _nur_vorrat(lo: Dictionary) -> Variant:
if not lo.has("grenades") and not lo.has("mines"):
return null
var out := {"mode": LoadoutKatalog.MODE_PLAYER}
for key in ["grenades", "mines"]:
if lo.has(key):
out[key] = lo[key]
return out
func hat_vorrat() -> bool:
return current().has("grenades") or current().has("mines")
## Vorrat festlegen (Start = Server-Standard) oder wieder dem Server überlassen.
func set_vorrat_aktiv(an: bool) -> void:
if main.doc.is_empty() or an == hat_vorrat():
return
var lo := current().duplicate(true)
if an:
if lo.is_empty():
lo["mode"] = LoadoutKatalog.MODE_PLAYER
lo["grenades"] = {"he1": 1, "blend1": 1, "smoke1": 1}
lo["mines"] = LoadoutKatalog.STANDARD_MINEN
_commit(lo, "Loadout: Granaten und Minen festgelegt")
return
lo.erase("grenades")
lo.erase("mines")
var leer := str(lo.get("mode", LoadoutKatalog.MODE_PLAYER)) == LoadoutKatalog.MODE_PLAYER
_commit(null if leer else lo, "Loadout: Granaten und Minen wie Server")
func set_granaten(gid: String, anzahl: int) -> void:
if main.doc.is_empty() or not hat_vorrat() or not LoadoutKatalog.GRANATEN.has(gid):
return
var lo := current().duplicate(true)
var g: Dictionary = lo.get("grenades", {})
if anzahl > 0:
g[gid] = clampi(anzahl, 0, LoadoutKatalog.MAX_GRANATEN_JE_ART)
else:
g.erase(gid)
lo["grenades"] = g
_commit(lo, "Loadout: %s" % str(LoadoutKatalog.GRANATEN[gid]))
func set_minen(anzahl: int) -> void:
if main.doc.is_empty() or not hat_vorrat():
return
var lo := current().duplicate(true)
lo["mines"] = clampi(anzahl, 0, LoadoutKatalog.MAX_MINEN)
_commit(lo, "Loadout: Minen")
## Waffen setzen. Aufsätze von Waffen, die nicht mehr im Loadout sind, fallen weg. ## Waffen setzen. Aufsätze von Waffen, die nicht mehr im Loadout sind, fallen weg.
@ -187,6 +285,12 @@ func refresh_values() -> void:
primary_option.select(maxi(0, _ids.find(prim))) primary_option.select(maxi(0, _ids.find(prim)))
secondary_option.select(_ids.find(sec) if sec != KEINE and _ids.has(sec) else _ids.size()) secondary_option.select(_ids.find(sec) if sec != KEINE and _ids.has(sec) else _ids.size())
_rebuild_attachments(prim, sec, lo.get("attachments", {})) _rebuild_attachments(prim, sec, lo.get("attachments", {}))
var vorrat := hat_vorrat()
vorrat_check.button_pressed = vorrat
vorrat_box.visible = vorrat
for gid in granaten_spins:
(granaten_spins[gid] as SpinBox).value = int((lo.get("grenades", {}) as Dictionary).get(gid, 0))
minen_spin.value = int(lo.get("mines", LoadoutKatalog.STANDARD_MINEN))
_loading = false _loading = false

View file

@ -123,6 +123,33 @@ func _test_panel() -> void:
panel.set_fixed(false) panel.set_fixed(false)
_check(not main.doc.has("loadout"), "Spieler-Loadout entfernt den Block") _check(not main.doc.has("loadout"), "Spieler-Loadout entfernt den Block")
# Granaten + Minen: Undo, Spieler-Modus, Validierung
_check(not panel.vorrat_check.button_pressed and not panel.vorrat_box.visible, "Vorrat aus: Server-Standard")
panel.set_vorrat_aktiv(true)
_check(main.doc["loadout"]["mode"] == "player" and main.doc["loadout"]["mines"] == 2, "Vorrat an: Block im Spieler-Modus, Standard 2 Minen")
_check(panel.vorrat_box.visible and panel.minen_spin.value == 2, "Eingabefelder sichtbar")
panel.set_granaten("he1", 3)
panel.set_minen(1)
_check(main.doc["loadout"]["grenades"]["he1"] == 3 and main.doc["loadout"]["mines"] == 1, "Anzahlen gespeichert")
panel.set_granaten("smoke1", 0)
_check(not main.doc["loadout"]["grenades"].has("smoke1"), "0 entfernt die Art")
main.history.undo()
main.call("_refresh_all")
_check(main.doc["loadout"]["grenades"]["smoke1"] == 1 and panel.granaten_spins["smoke1"].value == 1, "Undo holt die Art zurück, UI folgt")
panel.granaten_spins["blend1"].value = 4
_check(main.doc["loadout"]["grenades"]["blend1"] == 4, "Eingabefeld schreibt den Wert")
_check(LoadoutKatalog.validate(main.doc["loadout"]).is_empty(), "Vorrat gültig")
panel.set_fixed(true)
_check(panel.is_fixed() and main.doc["loadout"]["mines"] == 1, "Wechsel auf fest behält den Vorrat")
panel.set_fixed(false)
_check(main.doc["loadout"]["mode"] == "player" and main.doc["loadout"]["mines"] == 1, "Wechsel auf Spieler behält den Vorrat")
_check(not LoadoutKatalog.validate({"mode": "player", "mines": 4}).is_empty(), "zu viele Minen ungültig")
_check(not LoadoutKatalog.validate({"mode": "player", "grenades": {"he1": 6}}).is_empty(), "zu viele Granaten ungültig")
_check(not LoadoutKatalog.validate({"mode": "player", "grenades": {"nix": 1}}).is_empty(), "unbekannte Granate ungültig")
_check(not LoadoutKatalog.validate({"mode": "player", "grenades": 3}).is_empty(), "grenades ohne Objekt ungültig")
panel.set_vorrat_aktiv(false)
_check(not main.doc.has("loadout"), "Vorrat aus im Spieler-Modus entfernt den Block")
# Speichern/Laden # Speichern/Laden
panel.set_fixed(true) panel.set_fixed(true)
panel.set_weapons("sr2", "pistol2") panel.set_weapons("sr2", "pistol2")
@ -133,6 +160,13 @@ func _test_panel() -> void:
_check(res["ok"], "Speichern ok: " + str(res["errors"])) _check(res["ok"], "Speichern ok: " + str(res["errors"]))
var back := SvmapIO.load_from_file(path) var back := SvmapIO.load_from_file(path)
_check(back["ok"] and back["data"]["loadout"]["primary"] == "sr2", "Laden: Loadout bleibt erhalten") _check(back["ok"] and back["data"]["loadout"]["primary"] == "sr2", "Laden: Loadout bleibt erhalten")
panel.set_vorrat_aktiv(true)
panel.set_minen(3)
var doc3: Dictionary = main.doc.duplicate(true)
SvmapIO.sign_map(doc3)
res = SvmapIO.save_to_file(path, doc3)
back = SvmapIO.load_from_file(path)
_check(res["ok"] and back["ok"] and back["data"]["loadout"]["mines"] == 3, "Laden: Vorrat bleibt erhalten")
func _signed(d: Dictionary) -> Dictionary: func _signed(d: Dictionary) -> Dictionary: