diff --git a/src/svmap/loadout_katalog.gd b/src/svmap/loadout_katalog.gd index a76a54d..d106c6d 100644 --- a/src/svmap/loadout_katalog.gd +++ b/src/svmap/loadout_katalog.gd @@ -7,6 +7,8 @@ extends RefCounted ## 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). +## 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). const PATH := "res://library/loadout_katalog.json" @@ -20,6 +22,21 @@ const TEIL_NAMEN := { "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 _loaded := false @@ -135,6 +152,7 @@ static func validate(lo: Variant) -> PackedStringArray: if mode != MODE_FIXED and mode != MODE_PLAYER: errors.append("V18: loadout.mode muss 'fixed' oder 'player' sein (ist '%s')." % mode) return errors + errors.append_array(_pruefe_vorrat(d)) if mode == MODE_PLAYER: return errors var prim := str(d.get("primary", "")).strip_edges().to_lower() @@ -158,3 +176,26 @@ static func validate(lo: Variant) -> PackedStringArray: if why != "": errors.append("V18: loadout.attachments['%s']: %s." % [w, why]) 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) diff --git a/src/ui/panels/loadout_panel.gd b/src/ui/panels/loadout_panel.gd index deb15d0..e9cde80 100644 --- a/src/ui/panels/loadout_panel.gd +++ b/src/ui/panels/loadout_panel.gd @@ -3,7 +3,8 @@ extends "res://src/ui/panels/editor_panel.gd" ## 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. +## 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, ## ist das Spieler-Loadout gemeint). @@ -16,6 +17,10 @@ var secondary_option: OptionButton var fixed_box: VBoxContainer var attach_box: VBoxContainer 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 _ids: Array[String] = [] @@ -60,9 +65,31 @@ func build() -> void: 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.") + hint_label = _muted_label("Messer bleiben unverändert.") 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)) primary_option.item_selected.connect(func(i: int) -> void: set_weapons(_ids[i], _secondary_id())) @@ -71,6 +98,20 @@ func build() -> void: 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 ------- func _primary_id() -> String: @@ -97,15 +138,72 @@ func set_fixed(fixed: bool) -> void: if main.doc.is_empty(): return if not fixed: - _commit(null, "Loadout: Spieler-Loadout") + _commit(_nur_vorrat(current()), "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") + var neu := {"mode": LoadoutKatalog.MODE_FIXED, "primary": prim, "secondary": sec, "attachments": {}} + 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. @@ -187,6 +285,12 @@ func refresh_values() -> void: 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", {})) + 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 diff --git a/tests/test_loadout.gd b/tests/test_loadout.gd index 9167da8..cd8644d 100644 --- a/tests/test_loadout.gd +++ b/tests/test_loadout.gd @@ -123,6 +123,33 @@ func _test_panel() -> void: panel.set_fixed(false) _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 panel.set_fixed(true) panel.set_weapons("sr2", "pistol2") @@ -133,6 +160,13 @@ func _test_panel() -> void: _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") + 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: