Integracja qPanorama z pluginem CounterStrikeSharp
Instrukcja pokazuje, jak podłączyć qPanorama do własnego pluginu C#: pobrać API, zarejestrować layout, wyświetlić dane gracza i obsłużyć menu. Przykłady zakładają zainstalowane qPanorama i wspólne QPanoramaApi.dll na serwerze.
1. Kolejność integracji
Load
→ podłącz ProviderChanged i własne listenery
OnAllPluginsLoaded
→ pobierz capability qpanorama:api
→ OpenClient ze stałą nazwą pluginu
→ RegisterLayout dla każdego zasobu XML
GetStatus: Ready
→ zapisz tekst i klasy dla konkretnego gracza
Komenda otwarcia menu
→ przygotuj dane, pozyskaj capture, pokaż panel
Obsługa otwartego menu
→ sprawdzaj sesję i capture, odbieraj kliknięcia
Disconnect / zmiana mapy / wymiana providera
→ unieważnij lokalne sesje i pamięć zapisów
Unload
→ odepnij zdarzenia i zwolnij klienta
Konsument pobiera API w OnAllPluginsLoaded(bool hotReload). Capability rejestruje dostawca QPanoramaImpl; twój plugin nie wywołuje Capabilities.RegisterPluginCapability dla qPanorama. Tworzy jedynie obiekt PluginCapability<IQPanoramaApi> i wywołuje Get().
Deklaruj wszystkie layouty podczas inicjalizacji. Nie czekaj z rejestracją na pierwsze użycie komendy, join gracza ani otwarcie zakładki. Flaga konfiguracji wyłączająca UI może blokować jego pokazywanie, ale nie powinna opóźniać deklaracji zasobów.
2. Referencja w projekcie C#
Poniższy wariant zakłada, że wspólne QPanoramaApi.dll będzie zainstalowane na serwerze. Dodaj referencję do kontraktu, a nie do implementacji providera:
<ItemGroup>
<Reference Include="QPanoramaApi">
<HintPath>..\qpanorama-runtime\addons\counterstrikesharp\shared\QPanoramaApi\QPanoramaApi.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
Przykład zakłada, że katalog z plikami runtime znajduje się obok katalogu projektu:
workspace/
MojPlugin/
MojPlugin.csproj
qpanorama-runtime/
addons/counterstrikesharp/shared/QPanoramaApi/QPanoramaApi.dll
Dostosuj HintPath do własnego miejsca przechowywania DLL do kompilacji. Ścieżkę względną licz od katalogu .csproj. Dla projektu położonego głębiej dodaj odpowiednie poziomy ... Korzystaj z API odpowiadającego zainstalowanemu runtime.
Private=false zapobiega kopiowaniu API do wyjścia pluginu. Nie pakuj własnych kopii QPanoramaApi.dll, QPanoramaImpl.dll ani qpanorama.so. Wspólne API na serwerze ma jedną lokalizację:
addons/counterstrikesharp/shared/QPanoramaApi/QPanoramaApi.dll
Izolowany projekt testowy może mieć Private=true. Po zbudowaniu produkcyjnego pluginu sprawdź jego paczkę: nie może zawierać dodatkowej kopii API.
Brak API a brak providera to różne sytuacje. Przechwycenie błędu Get() obsługuje brak capability, ale nie gwarantuje załadowania klasy zawierającej typy z nieobecnego assembly API. Jeżeli plugin ma działać także bez samego QPanoramaApi.dll, zastosuj osobną granicę opcjonalnego ładowania, np. adapter refleksyjny bez typów API w klasie głównego pluginu. Przykład poniżej wymaga obecności assembly API.
3. Pobranie API i rejestracja w OnAllPluginsLoaded
Poniższy szkielet można skompilować jako plugin z referencjami do CounterStrikeSharp i QPanoramaApi. Obejmuje inicjalizację i zwalnianie klienta. Metody renderowania, komendy oraz sesje menu dołączasz w swoim pluginie zgodnie z dalszymi sekcjami.
using System.Collections.Generic;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Capabilities;
using Microsoft.Extensions.Logging;
using QPanoramaApi;
public sealed class MyPlugin : BasePlugin
{
public override string ModuleName => "MyPlugin";
public override string ModuleVersion => "1.0.0";
private static readonly PluginCapability<IQPanoramaApi> Panorama =
new(QPanoramaCapability.Name); // "qpanorama:api"
private IQPanoramaApi? api;
private IQPanoramaClient? client;
private IQPanoramaLayout? layout;
private bool allPluginsLoaded;
public override void Load(bool hotReload)
{
QPanoramaCapability.ProviderChanged += AcquirePanorama;
// Tutaj podłącz własne komendy i listenery cyklu życia graczy/mapy.
}
public override void OnAllPluginsLoaded(bool hotReload)
{
allPluginsLoaded = true;
AcquirePanorama();
}
private void AcquirePanorama()
{
if (!allPluginsLoaded) return;
IQPanoramaApi? next;
try { next = Panorama.Get(); }
catch (KeyNotFoundException) { next = null; }
// Nie otwieraj ponownie tego samego ownera przy zwykłym odświeżeniu.
if (ReferenceEquals(next, api) && client != null) return;
// Przed wymianą uchwytów unieważnij własne sesje UI i cache zapisów.
client?.Dispose();
client = null;
layout = null;
api = next;
if (next == null) return; // Własny fallback, np. zwykłe menu CSS.
var opened = next.OpenClient("MyPlugin");
if (!opened.IsSuccess || opened.Value == null)
{
Logger.LogWarning("qPanorama OpenClient: {Result}", opened.Result);
opened.Value?.Dispose();
return;
}
client = opened.Value;
var registered = client.RegisterLayout(
"main",
"panorama/layout/custom_game/myplugin.xml",
QPanoramaLayoutMode.PlayerIsolated);
// Nie odrzucaj zachowanej deklaracji oczekującej na świat/native.
if (registered.Value == null ||
(!registered.IsSuccess &&
registered.Result is not (QPanoramaResult.LayoutPending or
QPanoramaResult.LayoutNotReady)))
{
Logger.LogWarning("qPanorama RegisterLayout: {Result}", registered.Result);
client.Dispose();
client = null;
return;
}
layout = registered.Value;
}
private bool TryGetReady(out QPanoramaLayoutStatus status)
{
status = default;
if (api?.IsReady != true || layout == null) return false;
var read = layout.GetStatus();
if (!read.IsSuccess) return false;
status = read.Value;
return status.Ready && layout.LogicalId != 0;
}
public override void Unload(bool hotReload)
{
allPluginsLoaded = false;
QPanoramaCapability.ProviderChanged -= AcquirePanorama;
// Unieważnij sesje, hold bindings i odroczone operacje swojego pluginu.
// Odepnij również własne listenery/callbacki.
client?.Dispose();
client = null;
layout = null;
api = null;
}
}
Nie blokuj OpenClient i RegisterLayout warunkiem api.IsReady. Provider może już istnieć i przyjmować deklaracje, gdy native jeszcze się inicjalizuje. Zapis tekstu, klas i wejścia wykonuj dopiero po potwierdzeniu Ready przez GetStatus().
ProviderChanged oznacza konieczność ponownego pobrania capability. Gdy zmieni się obiekt providera, porzuć sesje, klienta, uchwyty i cache starej instancji. Ponowne OpenClient z tą samą nazwą zastępuje poprzedniego klienta tego ownera, więc nie wywołuj go przy każdym ticku ani przy otwieraniu menu.
Szkielet zakłada obecność providera podczas inicjalizacji lub późniejsze powiadomienie ProviderChanged. Jeśli wspierasz instalację API dopiero podczas pracy serwera, potrzebujesz kontrolowanego mechanizmu ponownego odkrywania; nie twórz nieograniczonej pętli ponownego rejestrowania layoutu.
4. Jak dopisać kolejne layouty
Jeden klient pluginu może deklarować kilka zasobów:
var hud = client.RegisterLayout(
"hud", "panorama/layout/custom_game/myplugin_hud.xml",
QPanoramaLayoutMode.PlayerIsolated);
var menu = client.RegisterLayout(
"menu", "panorama/layout/custom_game/myplugin_menu.xml",
QPanoramaLayoutMode.PlayerIsolated);
Dla każdego wyniku zastosuj sprawdzenie z sekcji 3 i zachowaj osobny uchwyt.
| Argument | Co oznacza | Przykład |
|---|---|---|
owner w OpenClient |
Stała nazwa pluginu | MyPlugin |
key w RegisterLayout |
Stały klucz layoutu w obrębie ownera | menu |
| resource | Ścieżka zasobu rozpoznawana przez klienta CS2 | panorama/layout/custom_game/myplugin_menu.xml |
| mode | Sposób prezentacji stanu gracza | PlayerIsolated |
Klucz nie jest ID panelu XML. Dwa różne zasoby tego samego ownera potrzebują różnych kluczy. Powtórna deklaracja tego samego klucza z innym zasobem lub trybem zwróci LayoutConflict.
Jeśli jeden XML zawiera HUD i wszystkie zakładki menu, zwykle wystarczy jeden layout. Przełączenie zakładki oznacza zmianę klas istniejących paneli, a nie kolejne RegisterLayout. Dołączony fragment XML należy do layoutu, który go włącza: nie rejestruj automatycznie każdego pliku z hierarchii include jako osobnego layoutu.
Używaj ścieżek względnych z /, zaczynających się od panorama/layout/custom_game/ i kończących na .xml. Nie przekazuj ścieżki dysku, adresu Workshop ani rozszerzenia .vxml_c do RegisterLayout.
Wybór trybu
PlayerIsolated: prywatny HUD/menu odbiorcy, także gdy obserwuje innego gracza. Wybierz go, jeśli obserwator ma zachować własny interfejs.Shared: prezentacja z semantyką obserwowanego gracza; używaj świadomie, gdy taki efekt jest zamierzony.
Overload bez argumentu mode wybiera Shared. Podaj tryb jawnie. Oba tryby mogą obsługiwać menu interaktywne; Shared nie oznacza „bez capture”.
5. Zasób XML/CSS i powiązanie z C#
Przygotuj zasoby należące do swojego pluginu/addonu. Przykładowy myplugin.xml:
<root>
<styles>
<include src="file://{resources}/styles/custom_game/myplugin.css" />
</styles>
<Panel id="myplugin_root" hittest="false">
<Panel id="myplugin_menu" class="MyPluginMenu Hidden">
<Label id="myplugin_title" text="{s:title}" hittest="false" />
<Button id="myplugin_close" class="MyPluginClose">
<Label text="Zamknij" hittest="false" />
</Button>
</Panel>
</Panel>
</root>
Minimalne ukrywanie w CSS:
.Hidden { visibility: collapse; }
Resztę wyglądu określasz w swoim CSS. Hidden jest nazwą klasy z tego pliku, a nie wbudowaną komendą qPanorama. SetClass(..., "Hidden", false) usuwa klasę, a true ją dodaje. Samo API nie tworzy panelu o podanym ID.
Zasoby skompiluj przez Workshop Tools i udostępnij klientowi przez używany addon/Workshop. Rejestracja zasobu na serwerze nie kompiluje XML/CSS i nie przesyła plików graczowi. Sprawdzaj zgodność z rzeczywistym CustomHud: na przykład Button nie dopuszcza hittest, mimo że kompilacja zasobu może nie zgłosić tego błędu. W powyższym przykładzie przycisk ma tylko id i class.
Nie potrzebujesz onactivate ani własnego JavaScript do transportu tych kliknięć: odbierasz statyczny ID przycisku przez PollClick.
6. Wyświetlanie tekstu i zmiana klas
Po sprawdzeniu gotowości layoutu i aktualnej tożsamości gracza:
int slot = player.Slot;
var text = layout.SetDialogString(slot, "myplugin_title", "title", player.PlayerName);
var shown = layout.SetClass(slot, "myplugin_menu", "Hidden", false);
if ((int)text < 0 || (int)shown < 0)
{
// Przerwij otwarcie/odświeżenie, odnotuj wynik i zastosuj własny fallback.
}
player oznacza aktualny, połączony kontroler człowieka; zweryfikuj go przed wywołaniem. Nie odrzucaj gracza tylko dlatego, że nie żyje lub jest spectatorem.
| Parametr | Wartość z przykładu |
|---|---|
| slot | player.Slot, nie UserId, indeks encji ani SteamID |
| panel | myplugin_title, czyli id elementu XML |
| variable | title, czyli nazwa w {s:title} |
| value | Oryginalny tekst do pokazania |
ID panelu i nazwa zmiennej mogą być różne. W przykładzie panel ma ID myplugin_title, a zmienna nazywa się title. Pierwszy identyfikator wskazuje element XML, drugi odpowiada wpisowi {s:title}.
Pasywny HUD aktualizujesz tak samo, ale bez pobierania capture. Zapisuj wyłącznie zmienione wartości. Cache oznaczaj jako zastosowany dopiero po udanym wyniku API. Po zmianie generacji mapy lub sesji wyczyść cache, nawet jeśli treść nadal jest taka sama: nowy stan native wymaga ponownego zapisu.
SetGlobalDialogString i SetGlobalClass służą do jawnie publicznych, wspólnych wartości domyślnych layoutu. Nie używaj ich do prywatnego nicku, ekwipunku, danych administracyjnych ani indywidualnego menu. Nie zapisuj prywatnych wartości pod slotem -1.
Tekst nie jest identyfikatorem
Przekazuj oryginalne wartości wyświetlania, np. Qesik <3, {s:player_name} czy 👩🏽💻. Nie dodawaj własnego XML/HTML escaping i nie usuwaj ZWJ „dla bezpieczeństwa”. qPanorama centralnie obsługuje bezpieczeństwo tekstu. Zachowaj reguły domenowe swojego pluginu, np. limit długości powodu w bazie i kontrolę uprawnień.
Owner, key, panel, variable, class, resource i button ID muszą pochodzić z zaufanego kodu/zasobu. Nigdy nie podstawiaj nicku lub tekstu gracza jako ID, klasy, ścieżki czy tokenu akcji.
7. Otwieranie menu i capture
Przy jawnej komendzie otwarcia:
- Zweryfikuj aktualnego gracza, wymagane uprawnienia i gotowość layoutu.
- Przygotuj dane panelu, utrzymując go jeszcze ukrytym. Sprawdź wyniki zapisów.
- Wywołaj
SetInputCapture(slot, true)i sprawdź wynik. - Pokaż panel przez
SetClass(slot, "myplugin_menu", "Hidden", false). - Dopiero po sukcesie zapamiętaj otwartą sesję menu.
Fragment kroku 3–4, wykonywany na wątku gry, po wcześniejszej walidacji:
var capture = layout.SetInputCapture(slot, true);
if ((int)capture < 0) return;
var show = layout.SetClass(slot, "myplugin_menu", "Hidden", false);
if ((int)show < 0)
{
layout.SetInputCapture(slot, false);
return;
}
// Teraz zapamiętaj sesję powiązaną z aktualną tożsamością i generacjami.
SetInputCapture(slot, true) przejmuje interaktywny fokus od poprzedniego layoutu tego gracza. Nie zakładaj, że zajęty fokus zwróci InputCaptureBusy.
Jeśli operacja ma pozyskać wyłącznie wolny fokus, użyj opcjonalnego kontraktu:
if (layout is not IQPanoramaInputCapture nonStealing) return;
var acquired = nonStealing.TryAcquireInputCapture(slot);
if (acquired != QPanoramaResult.Ok) return;
// Pokaż własne UI; po błędzie pokazywania zwolnij właśnie pozyskany capture.
InputCaptureBusy oznacza tu brak przejęcia i brak skutków ubocznych. Nie zastępuj nieudanej próby automatycznym SetInputCapture(true), bo zmieniłoby to znaczenie operacji.
8. Utrata fokusu, TAB i zamykanie
Dla każdej otwartej sesji sprawdzaj IsInputCaptureEnabled(slot) przed odświeżeniem menu i obsługą kliknięć. Najpierw zweryfikuj tożsamość, generacje oraz gotowość layoutu; nieważną sesję porzuć bez zapisów do starego uchwytu.
var capture = layout.IsInputCaptureEnabled(slot);
if (!capture.IsSuccess || !capture.Value)
{
layout.SetClass(slot, "myplugin_menu", "Hidden", true);
// Zakończ tę sesję, wyczyść draft i unieważnij odroczone akcje.
// Nie wysyłaj release ani ponownego capture po utracie właścicielstwa.
return;
}
Ukryj tylko swoje interaktywne panele. Pasywny HUD może pozostać. Jeśli ukrycie się nie powiedzie, nie oznaczaj go jako wykonanego: zakończ możliwość akcji i zachowaj kontrolowaną próbę ukrycia, ważną tylko dla tej samej tożsamości/generacji.
Przy zwykłym zamknięciu własnego menu ukryj panel i zwolnij jego capture przez SetInputCapture(slot, false). Release innego layoutu nie odbierze nowemu właścicielowi fokusu. Stare odroczone zamknięcie może jednak dotyczyć nowej sesji tego samego layoutu, dlatego zawsze sprawdzaj własny numer sesji.
Naciśnięcie TAB odbiera aktywny foreground przez qPanorama. Konsument wykrywa utratę capture i ukrywa menu powyższą ścieżką. Nie dodawaj osobnego hooka TAB do każdego pluginu i nie odzyskuj fokusu automatycznie w ticku.
9. Kliknięcia: jedna kolejka klienta, jawne akcje
client.PollClick(out click) zwraca Ok, QueueEmpty albo błąd. TryPollClick ukrywa rozróżnienie pustej kolejki i błędu, dlatego do diagnostyki preferuj PollClick.
Odbieraj ograniczoną liczbę zdarzeń w jednym przebiegu, np. 32. Przerwij przy QueueEmpty, a inny błąd potraktuj jako problem transportu. Jeden klient ma wspólną kolejkę dla swoich layoutów: odczytuj ją w jednym miejscu i kieruj zdarzenia według click.IsFor(layout) / LayoutId, zamiast opróżniać ją osobno dla każdej zakładki.
Przed wykonaniem akcji sprawdź:
- Layout zdarzenia i rosnącą
Sequencew obrębie bieżącego życia kolejki. - Aktualnego gracza z
click.PlayerSloti zgodnośćplayer.SteamIDzclick.SteamId64. - Własną tożsamość połączenia, sesję menu i generację mapy/providera.
- Capture nadal należący do tego layoutu.
- Aktualny widok oraz to, czy przycisk był dostępny w tej sesji.
- Uprawnienia i dane domenowe, ponownie sprawdzone w momencie akcji.
Dopiero po tych kontrolach rozdziel akcje jawnym switch po stałych ID, np. myplugin_close lub myplugin_confirm. Nie wykonuj Server.ExecuteCommand(click.ButtonId) i nie traktuj kliknięcia jako autoryzacji operacji.
GetQueueStats() pozwala kontrolować Dropped, Queued i Capacity. Wzrost Dropped oznacza utratę zdarzeń; unieważnij zależne interakcje/potwierdzenia zamiast zgadywać ich stan. Nie dopuszczaj kliknięć starej sesji do nowo otwartego menu: stosuj kontrolowany odbiór kolejki i bariery sesji/widoku. Opróżniając kolejkę, nie gub zdarzeń innych aktywnych graczy tego samego klienta.
10. Sesje, mapy i ponowne połączenia
Sam slot nie identyfikuje osoby ani sesji. W lokalnym stanie przechowuj co najmniej:
- slot oraz SteamID64;
- tożsamość kontrolera z serialem i własną generację połączenia;
- generację providera;
MapGenerationoraz tożsamość layoutu;- własny rosnący numer sesji menu, a w razie potrzeby także widoku/draftu.
| Zdarzenie | Obowiązek konsumenta |
|---|---|
| Disconnect / ponowne użycie slotu | Unieważnij sesję, draft i cache poprzedniego połączenia |
Zmiana mapy / MapGeneration |
Porzuć stare interakcje; po Ready odtwórz aktualną prezentację |
| Wymiana providera | Ponownie pobierz API i zarejestruj layouty na nowym kliencie |
| Utrata capture | Ukryj interaktywne UI i zakończ sesję bez przejmowania fokusu |
| Unload | Odepnij ProviderChanged, własne listenery, hold bindings i zwolnij klienta |
Nie rejestruj layoutu na każdy respawn ani po każdym odczycie nowej mapy. Istniejąca deklaracja przechodzi przez cykl mapy; czekasz na Ready i odtwarzasz potrzebne dane. Utrata gotowości także unieważnia otwartą interakcję, nawet jeśli później wróci ten sam numer mapy.
11. Baza danych, HTTP i wątek gry
Wszystkie wywołania API, rejestracja i Dispose należą do wątku gry CSS. Nie wywołuj ich bezpośrednio z Task.Run, kontynuacji zapytania SQL ani callbacku HTTP.
Przenieś wynik do używanego w pluginie mechanizmu wykonania na wątku gry, np. Server.NextFrame. W callbacku ponownie sprawdź, czy plugin nadal działa i czy tożsamość, mapa, provider oraz sesja odpowiadają wartościom zapamiętanym przed zapytaniem. Stary wynik nie może pokazać zamkniętego menu ani zamknąć nowo otwartego.
Nie wysyłaj wszystkich tekstów i klas co tick. Obserwuj tylko aktywne sesje, stosuj cache udanych zapisów i aktualizuj prezentację po rzeczywistej zmianie danych.
12. Opcjonalne menu otwierane przytrzymaniem Inspect
Zwykła komenda używa capture z sekcji 7. Jeśli potrzebujesz sesji „naciśnij/przytrzymaj/puść”, sprawdź layout is IQPanoramaHoldInput i użyj ArmHold z QPanoramaInputAction.Inspect.
W callbackach open / close tylko przygotowuj i pokazuj/ukrywaj własną prezentację. Nie wywołuj tam SetInputCapture ani TryAcquireInputCapture: capture obsługuje koordynator. Porównuj pełny QPanoramaHoldSession ze swoją bieżącą sesją. Przy własnym wcześniejszym zamknięciu użyj NotifyClosed(session), a przy końcu życia bindingu Disarm().
Na obiekcie IQPanoramaHoldInput wywołujesz metodę o następującej sygnaturze; nie implementujesz jej samodzielnie:
QPanoramaValue<IQPanoramaHoldBinding?> ArmHold(
int slot,
QPanoramaInputAction action,
ulong consumerToken,
Func<QPanoramaHoldSession, QPanoramaHoldOpenResult> open,
Action<QPanoramaHoldSession, QPanoramaHoldCloseReason> close,
bool replace = false);
consumerToken: własny numer sesji/widoku, zwracany bez zmian w callbackach.open: sprawdź tożsamość i token, zapamiętaj sesję, przygotuj i pokaż panel. ZwróćOpeneddopiero po sukcesie. Pozostałe wyniki toRejected,Unavailable,StaleiFailed.close: porównaj otrzymaną sesję z zapamiętaną i ukryj wyłącznie odpowiadającą jej interakcję.- Wynik
ArmHold: sprawdźIsSuccessi niepusteValue, a następnie zachowaj otrzymany binding. replace: domyślniefalse; nie zastępuj istniejącego bindingu bez świadomej decyzji.
Przy własnym zamknięciu najpierw ukryj panel i zakończ dokładnie tę sesję, potem wywołaj binding.NotifyClosed(session). Przy odłączaniu obsługi wywołaj binding.Disarm().
Zwolnienie Inspect nie może zamknąć później otwartego menu komendowego: callback musi sprawdzić sesję hold, zamiast zamykać dowolny panel w slocie. Jeśli plugin nie potrzebuje tego zachowania, pomiń integrację hold.