Система голосования Panorama позволяет создавать пользовательские голосования Да/Нет, которые отображаются в интерфейсе CS2. Игроки могут голосовать с помощью встроенного игрового интерфейса, что делает процесс естественным и знакомым.
Ключевые возможности:
Интеграция с нативным UI CS2 Настраиваемые сообщения голосования и текст успеха/провала Выборочное таргетирование игроков с использованием битовой маски Обратные вызовы событий голосования (начало, голос, конец) Пользовательская логика успеха/провала с подробной статистикой голосования Автоматическая обработка тайм-аута Система голосования использует два типа обратных вызовов:
Вызывается для событий голосования (начало, индивидуальные голоса, конец):
void Handler ( VoteAction action , int param1 , int param2 )
VoteAction.Start : Голосование началось (param1 и param2 не используются)VoteAction.Vote : Игрок проголосовал (param1 = слот клиента, param2 = выбор)
VOTE_OPTION1 (0) = ДаVOTE_OPTION2 (1) = НетVoteAction.End : Голосование завершено (param1 = -1, param2 = VoteEndReason)Вызывается в конце голосования для определения, прошло ли оно:
bool Result ( int numVotes , int yesVotes , int noVotes , int numClients ,
int [] clientInfoSlot , int [] clientInfoItem )
Возвращает : true для прохождения голосования, false для провалаnumVotes : Всего отдано голосовyesVotes : Количество голосов "Да"noVotes : Количество голосов "Нет"numClients : Общее количество клиентов в пуле голосованияclientInfoSlot : Массив слотов игроков, которые проголосовалиclientInfoItem : Массив выборов голосования (соответствует clientInfoSlot)Есть две основные функции для начала голосования:
PanoramaSendYesNoVoteToAll - Создает голосование для всех игроковPanoramaSendYesNoVote - Создает голосование для определенных игроков (используя битовую маску получателей)
using Plugify ;
using static s2sdk . s2sdk ;
public unsafe class VoteExample : Plugin
{
public void OnPluginStart ()
{
// Создаем простое голосование для всех игроков
PanoramaSendYesNoVoteToAll (
duration : 30.0 ,
caller : VOTE_CALLER_SERVER,
voteTitle : "#SFUI_vote_passed" ,
detailStr : "Restart the match?" ,
votePassTitle : "#SFUI_vote_passed" ,
detailPassStr : "Match restarting" ,
failReason : 0 ,
result : OnVoteResult,
handler : OnVoteHandler
);
}
// Вызывается для определения, прошло ли голосование
private bool OnVoteResult ( int numVotes , int yesVotes , int noVotes ,
int numClients , int [] clientInfoSlot , int [] clientInfoItem )
{
PrintToServer ( $"Vote finished: { yesVotes } Yes, { noVotes } No, " +
$" { numVotes } / { numClients } voted \n " );
// Простое большинство: более 50% голосов "Да"
return yesVotes > noVotes;
}
// Вызывается для событий голосования
private void OnVoteHandler ( VoteAction action , int param1 , int param2 )
{
switch (action)
{
case VoteAction . Start :
PrintToServer ( "Vote started! \n " );
break ;
case VoteAction . Vote :
int clientSlot = param1;
int choice = param2;
string vote = choice == ( int )CastVote.VOTE_OPTION1 ? "Yes" : "No" ;
PrintToServer ( $"Player { clientSlot } voted: { vote } \n " );
break ;
case VoteAction . End :
VoteEndReason reason = ( VoteEndReason )param2;
PrintToServer ( $"Vote ended: { reason } \n " );
// Выполнить действие на основе результата
// Примечание: Вызывается ПОСЛЕ того, как OnVoteResult определит успех/провал
break ;
}
}
}
#include <plugify/cpp_plugin.hpp>
#include "s2sdk.hpp"
using namespace s2sdk ;
class VoteExample : public plg :: IPluginEntry {
public:
void OnPluginStart () override {
PanoramaSendYesNoVoteToAll (
30.0 ,
VOTE_CALLER_SERVER,
"#SFUI_vote_passed" ,
"Restart the match?" ,
"#SFUI_vote_passed" ,
"Match restarting" ,
0 ,
// Result callback - определяет успех/провал
[]( int numVotes , int yesVotes , int noVotes , int numClients ,
const plg :: vector < int > & clientInfoSlot ,
const plg :: vector < int > & clientInfoItem ) -> bool {
PrintToServer ( std :: format ( "Vote: {} Yes, {} No, {}/{} voted \n " ,
yesVotes, noVotes, numVotes, numClients). c_str ());
return yesVotes > noVotes; // Простое большинство
},
// Handler callback - события голосования
[]( VoteAction action , int param1 , int param2 ) {
switch (action) {
case VoteAction :: Start :
PrintToServer ( "Vote started! \n " );
break ;
case VoteAction :: Vote : {
int clientSlot = param1;
int choice = param2;
const char* vote = choice == static_cast<int> ( CastVote ::VOTE_OPTION1)
? "Yes" : "No" ;
PrintToServer ( std :: format ( "Player {} voted: {} \n " ,
clientSlot, vote). c_str ());
break ;
}
case VoteAction :: End : {
VoteEndReason reason = static_cast< VoteEndReason > (param2);
PrintToServer ( "Vote ended \n " );
break ;
}
}
}
);
}
};
from plugify.plugin import Plugin
from plugify.pps import s2sdk as s2
class VoteExample ( Plugin ):
def plugin_start (self):
def on_vote_result (num_votes, yes_votes, no_votes, num_clients,
client_info_slot, client_info_item):
s2.PrintToServer( f "Vote: { yes_votes } Yes, { no_votes } No, " +
f " { num_votes } / { num_clients } voted \n " )
return yes_votes > no_votes # Простое большинство
def on_vote_handler (action, param1, param2):
if action == s2.VoteAction.Start:
s2.PrintToServer( "Vote started! \n " )
elif action == s2.VoteAction.Vote:
client_slot = param1
choice = param2
vote = "Yes" if choice == s2.CastVote. VOTE_OPTION1 else "No"
s2.PrintToServer( f "Player { client_slot } voted: { vote }\n " )
elif action == s2.VoteAction.End:
reason = param2 # VoteEndReason
s2.PrintToServer( f "Vote ended: { reason }\n " )
s2.PanoramaSendYesNoVoteToAll(
'''duration=''' 30.0 ,
'''caller=''' s2. VOTE_CALLER_SERVER ,
'''vote_title='''"#SFUI_vote_passed" ,
'''detail_str='''"Restart the match?" ,
'''vote_pass_title='''"#SFUI_vote_passed" ,
'''detail_pass_str='''"Match restarting" ,
'''fail_reason=''' 0 ,
'''result=''' on_vote_result,
'''handler=''' on_vote_handler
)
package main
import (
" fmt "
" s2sdk "
" github.com/untrustedmodders/go-plugify "
)
func init () {
plugify. OnPluginStart ( func () {
// Result callback
onVoteResult := func ( numVotes , yesVotes , noVotes , numClients int ,
clientInfoSlot , clientInfoItem [] int ) bool {
s2sdk. PrintToServer (fmt. Sprintf ( "Vote: %d Yes, %d No, %d / %d voted \n " ,
yesVotes, noVotes, numVotes, numClients))
return yesVotes > noVotes
}
// Handler callback
onVoteHandler := func ( action s2sdk . VoteAction , param1 , param2 int ) {
switch action {
case s2sdk.VoteActionStart:
s2sdk. PrintToServer ( "Vote started! \n " )
case s2sdk.VoteActionVote:
clientSlot := param1
choice := param2
vote := "No"
if choice == int (s2sdk.VOTE_OPTION1) {
vote = "Yes"
}
s2sdk. PrintToServer (fmt. Sprintf ( "Player %d voted: %s\n " ,
clientSlot, vote))
case s2sdk.VoteActionEnd:
reason := param2 // VoteEndReason
s2sdk. PrintToServer (fmt. Sprintf ( "Vote ended: %d\n " , reason))
}
}
s2sdk. PanoramaSendYesNoVoteToAll (
30.0 ,
s2sdk.VOTE_CALLER_SERVER,
"#SFUI_vote_passed" ,
"Restart the match?" ,
"#SFUI_vote_passed" ,
"Match restarting" ,
0 ,
onVoteResult,
onVoteHandler,
)
})
}
import { Plugin } from 'plugify' ;
import * as s2 from ':s2sdk' ;
export class VoteExample extends Plugin {
pluginStart () {
s2. PanoramaSendYesNoVoteToAll (
30.0 ,
s2. VOTE_CALLER_SERVER ,
"#SFUI_vote_passed" ,
"Restart the match?" ,
"#SFUI_vote_passed" ,
"Match restarting" ,
0 ,
// Result callback
(numVotes, yesVotes, noVotes, numClients,
clientInfoSlot, clientInfoItem) => {
s2. PrintToServer ( `Vote: ${ yesVotes } Yes, ${ noVotes } No, ` +
`${ numVotes }/${ numClients } voted \n ` );
return yesVotes > noVotes;
},
// Handler callback
( action , param1 , param2 ) => {
if (action === s2.VoteAction.Start) {
s2. PrintToServer ( "Vote started! \n " );
} else if (action === s2.VoteAction.Vote) {
const clientSlot = param1;
const choice = param2;
const vote = choice === s2.CastVote. VOTE_OPTION1 ? "Yes" : "No" ;
s2. PrintToServer ( `Player ${ clientSlot } voted: ${ vote } \n ` );
} else if (action === s2.VoteAction.End) {
const reason = param2;
s2. PrintToServer ( `Vote ended: ${ reason } \n ` );
}
}
);
}
}
local plugify = require 'plugify'
local Plugin = plugify. Plugin
local s2 = require 's2sdk'
local VoteExample = {}
setmetatable (VoteExample, { __index = Plugin })
function VoteExample : plugin_start ()
local function on_vote_result (num_votes, yes_votes, no_votes, num_clients,
client_info_slot, client_info_item)
s2 : PrintToServer ( string.format ( "Vote: %d Yes, %d No, %d/%d voted \n " ,
yes_votes, no_votes, num_votes, num_clients))
return yes_votes > no_votes
end
local function on_vote_handler (action, param1, param2)
if action == s2. VoteAction . Start then
s2 : PrintToServer ( "Vote started! \n " )
elseif action == s2. VoteAction . Vote then
local client_slot = param1
local choice = param2
local vote = choice == s2. CastVote . VOTE_OPTION1 and "Yes" or "No"
s2 : PrintToServer ( string.format ( "Player %d voted: %s \n " ,
client_slot, vote))
elseif action == s2. VoteAction . End then
local reason = param2
s2 : PrintToServer ( string.format ( "Vote ended: %d \n " , reason))
end
end
s2 : PanoramaSendYesNoVoteToAll (
30.0 ,
s2. VOTE_CALLER_SERVER ,
"#SFUI_vote_passed" ,
"Restart the match?" ,
"#SFUI_vote_passed" ,
"Match restarting" ,
0 ,
on_vote_result,
on_vote_handler
)
end
local M = {}
M. VoteExample = VoteExample
return M
Значения выбора голосования, используемые в обратных вызовах:
Значение Описание VOTE_OPTION1 (0)Голос "Да" VOTE_OPTION2 (1)Голос "Нет" VOTE_NOTINCLUDED (-1)Игрок не включен в голосование VOTE_UNCAST (5)Игрок не проголосовал
События, передаваемые в обратный вызов обработчика:
Значение Описание param1 param2 StartГолосование началось не используется не используется VoteИгрок проголосовал слот клиента выбор голоса (CastVote) EndГолосование завершено -1 VoteEndReason
Причины завершения голосования:
Значение Описание AllVotesВсе возможные голоса были отданы TimeUpВремя голосования истекло CancelledГолосование было отменено вручную
Чтобы создать голосование для определенных игроков, используйте PanoramaSendYesNoVote с битовой маской получателей . Каждый бит в uint64 представляет слот игрока:
Бит 0 (значение 1) = Слот игрока 0Бит 1 (значение 2) = Слот игрока 1Бит 2 (значение 4) = Слот игрока 2Бит N (значение 2^N) = Слот игрока NПостроение битовой маски: Установите бит N в 1, чтобы включить слот игрока N в голосование. Значение получателей - это сумма всех включенных битовых значений игроков.
// Включить определенных игроков по слотам
public ulong BuildRecipients ( int [] playerSlots )
{
ulong recipients = 0 ;
foreach ( int slot in playerSlots)
{
if (slot >= 0 && slot < 64 )
{
recipients |= ( 1UL << slot);
}
}
return recipients;
}
// Примеры:
// Только игрок в слоте 0
ulong player0Only = 1UL << 0 ; // = 1
// Только игрок в слоте 5
ulong player5Only = 1UL << 5 ; // = 32
// Игроки 0, 1 и 2
ulong players012 = ( 1UL << 0 ) | ( 1UL << 1 ) | ( 1UL << 2 ); // = 7
// Использование в голосовании
int [] targetPlayers = { 0 , 2 , 5 };
ulong recipients = BuildRecipients (targetPlayers);
PanoramaSendYesNoVote (
duration : 30.0 ,
caller : VOTE_CALLER_SERVER,
voteTitle : "#SFUI_vote_passed" ,
detailStr : "Kick player?" ,
votePassTitle : "#SFUI_vote_passed" ,
detailPassStr : "Player kicked" ,
failReason : 0 ,
recipients : recipients,
result : OnVoteResult,
handler : OnVoteHandler
);
def build_recipients (player_slots):
"""Создать битовую маску получателей из списка слотов игроков"""
recipients = 0
for slot in player_slots:
if 0 <= slot < 64 :
recipients |= ( 1 << slot)
return recipients
# Примеры:
player_0_only = 1 << 0 # = 1
player_5_only = 1 << 5 # = 32
players_012 = ( 1 << 0 ) | ( 1 << 1 ) | ( 1 << 2 ) # = 7
# Использование в голосовании
target_players = [ 0 , 2 , 5 ]
recipients = build_recipients(target_players)
s2.PanoramaSendYesNoVote(
'''duration=''' 30.0 ,
'''caller=''' s2. VOTE_CALLER_SERVER ,
'''vote_title='''"#SFUI_vote_passed" ,
'''detail_str='''"Kick player?" ,
'''vote_pass_title='''"#SFUI_vote_passed" ,
'''detail_pass_str='''"Player kicked" ,
'''fail_reason=''' 0 ,
'''recipients=''' recipients,
'''result=''' on_vote_result,
'''handler=''' on_vote_handler
)
Обратный вызов result позволяет реализовать пользовательскую логику для определения прохождения голосования:
// Требуется 60% голосов "Да"
private bool OnVoteResult ( int numVotes , int yesVotes , int noVotes ,
int numClients , int [] clientInfoSlot , int [] clientInfoItem )
{
if (numVotes == 0 ) return false ;
double yesPercent = ( double )yesVotes / numVotes * 100.0 ;
bool passed = yesPercent >= 60.0 ;
PrintToServer ( $"Vote: { yesPercent : F1 } % yes ( { yesVotes } / { numVotes } ) \n " );
return passed;
}
// Требуется большинство ВСЕХ клиентов (не только проголосовавших)
private bool OnVoteResultStrict ( int numVotes , int yesVotes , int noVotes ,
int numClients , int [] clientInfoSlot , int [] clientInfoItem )
{
bool passed = yesVotes > (numClients / 2 );
PrintToServer ( $"Vote: { yesVotes } / { numClients } clients voted yes \n " );
return passed;
}
// Анализ индивидуальных голосов
private bool OnVoteResultDetailed ( int numVotes , int yesVotes , int noVotes ,
int numClients , int [] clientInfoSlot , int [] clientInfoItem )
{
PrintToServer ( "Vote details: \n " );
for ( int i = 0 ; i < clientInfoSlot.Length; i ++ )
{
int slot = clientInfoSlot[i];
int choice = clientInfoItem[i];
string vote = choice == ( int )CastVote.VOTE_OPTION1 ? "Yes" : "No" ;
PrintToServer ( $" Slot { slot } : { vote } \n " );
}
return yesVotes > noVotes;
}
Система голосования предоставляет несколько утилитарных функций:
bool isVoting = PanoramaIsVoteInProgress ();
if (isVoting)
{
PrintToServer ( "A vote is currently active! \n " );
}
Удаляет игрока из текущего пула голосования (он больше не может голосовать):
PanoramaRemovePlayerFromVote ( 3 ); // Удалить игрока в слоте 3
int playerSlot = 5 ;
if ( PanoramaIsPlayerInVotePool (playerSlot))
{
PrintToServer ( $"Player { playerSlot } can vote \n " );
}
Принудительно обновляет UI голосования для конкретного игрока:
bool success = PanoramaRedrawVoteToClient (playerSlot);
PanoramaEndVote (VoteEndReason.Cancelled);
Разрешить только контр-террористам голосовать за смену карты:
public void StartMapVote ()
{
// Создать битовую маску получателей только для команды CT
ulong recipients = 0 ;
for ( int slot = 0 ; slot < 64 ; slot ++ )
{
int handle = PlayerSlotToEntHandle (slot);
if (handle == - 1 ) continue ;
int team = GetEntSchemaInt32 (handle, "CBaseEntity" , "m_iTeamNum" , 0 );
if (team == 3 ) // 3 = Counter-Terrorist
{
recipients |= ( 1UL << slot);
}
}
if (recipients == 0 )
{
PrintToServer ( "No CT players to vote! \n " );
return ;
}
PanoramaSendYesNoVote (
duration : 30.0 ,
caller : VOTE_CALLER_SERVER,
voteTitle : "#SFUI_vote_passed" ,
detailStr : "Change map to de_dust2?" ,
votePassTitle : "#SFUI_vote_passed" ,
detailPassStr : "Changing map to de_dust2" ,
failReason : 0 ,
recipients : recipients,
result : ( numVotes , yesVotes , noVotes , numClients , slots , items ) =>
{
return yesVotes > noVotes;
},
handler : ( action , param1 , param2 ) =>
{
if (action == VoteAction.End)
{
// Проверить, прошло ли голосование (определяется обратным вызовом result)
// Выполнить смену карты при необходимости
ServerCommand ( "changelevel de_dust2" );
}
}
);
}
private int totalYesVotes = 0 ;
private int totalNoVotes = 0 ;
public void StartTrackedVote ()
{
totalYesVotes = 0 ;
totalNoVotes = 0 ;
PanoramaSendYesNoVoteToAll (
duration : 30.0 ,
caller : VOTE_CALLER_SERVER,
voteTitle : "#SFUI_vote_passed" ,
detailStr : "Restart the match?" ,
votePassTitle : "#SFUI_vote_passed" ,
detailPassStr : "Match restarting" ,
failReason : 0 ,
result : ( numVotes , yesVotes , noVotes , numClients , slots , items ) =>
{
PrintToServer ( $"Final: { yesVotes } Yes, { noVotes } No \n " );
return yesVotes > (numClients / 2 ); // Большинство всех клиентов
},
handler : ( action , param1 , param2 ) =>
{
switch (action)
{
case VoteAction . Start :
PrintToChatAll ( "Vote started: Restart match?" );
break ;
case VoteAction . Vote :
int choice = param2;
if (choice == ( int )CastVote.VOTE_OPTION1)
totalYesVotes ++ ;
else if (choice == ( int )CastVote.VOTE_OPTION2)
totalNoVotes ++ ;
PrintToChatAll ( $"Current: { totalYesVotes } Yes, { totalNoVotes } No" );
break ;
case VoteAction . End :
VoteEndReason reason = ( VoteEndReason )param2;
PrintToChatAll ( $"Vote ended: { reason } " );
break ;
}
}
);
}
public void StartKickVote ( int callerSlot , int targetSlot )
{
if ( PanoramaIsVoteInProgress ())
{
PrintToChat (callerSlot, "A vote is already in progress!" );
return ;
}
int targetHandle = PlayerSlotToEntHandle (targetSlot);
string targetName = GetPlayerName (targetHandle);
PanoramaSendYesNoVoteToAll (
duration : 30.0 ,
caller : callerSlot,
voteTitle : "#SFUI_vote_kick_player" ,
detailStr : targetName,
votePassTitle : "#SFUI_vote_passed" ,
detailPassStr : $"Kicking { targetName } " ,
failReason : 0 ,
result : ( numVotes , yesVotes , noVotes , numClients , slots , items ) =>
{
// Требуется 60% голосов "Да"
return numVotes > 0 && (( double )yesVotes / numVotes) >= 0.6 ;
},
handler : ( action , param1 , param2 ) =>
{
if (action == VoteAction.Start)
{
// Удалить цель из пула голосования
PanoramaRemovePlayerFromVote (targetSlot);
PrintToChatAll ( $"Vote: Kick { targetName } ?" );
}
else if (action == VoteAction.End)
{
VoteEndReason reason = ( VoteEndReason )param2;
if (reason != VoteEndReason.Cancelled)
{
// Кикнуть игрока, если голосование прошло (определяется обратным вызовом result)
ServerCommand ( $"kickid { targetSlot } " );
}
}
}
);
}
Система голосования использует встроенные строки перевода CS2. Распространенные включают:
Строка перевода Описание #SFUI_vote_passedСообщение о прохождении голосования #SFUI_vote_failedСообщение о провале голосования #SFUI_vote_kick_playerГолосование за кик игрока #Panorama_voteОбщее сообщение голосования
Требование строк перевода: Используйте только строки перевода, начинающиеся с #SFUI_vote или #Panorama_vote. Другие строки могут некорректно отображаться в UI голосования.
Метод Описание PanoramaSendYesNoVoteToAll(duration, caller, voteTitle, detailStr, votePassTitle, detailPassStr, failReason, result, handler)Начать голосование для всех игроков PanoramaSendYesNoVote(duration, caller, voteTitle, detailStr, votePassTitle, detailPassStr, failReason, recipients, result, handler)Начать голосование для определенных игроков (используя битовую маску)
duration (double) - Максимальное время в секундах для голосованияcaller (int) - Слот игрока, который инициировал голосование, или VOTE_CALLER_SERVER для голосований сервераvoteTitle (string) - Строка перевода для сообщения голосования (например, #SFUI_vote_passed)detailStr (string) - Дополнительный текст деталей для голосованияvotePassTitle (string) - Строка перевода, показываемая при прохождении голосованияdetailPassStr (string) - Дополнительный текст деталей при прохождении голосованияfailReason (int) - Код причины провала голосованияrecipients (uint64) - Битовая маска слотов игроков, которые могут голосовать (только для PanoramaSendYesNoVote)result (обратный вызов YesNoVoteResult) - Функция, которая определяет, прошло ли голосование:
bool Result ( int numVotes , int yesVotes , int noVotes , int numClients ,
int [] clientInfoSlot , int [] clientInfoItem )
Возвращает true для прохождения голосования, false для провала numVotes: Всего отдано голосовyesVotes: Количество голосов "Да"noVotes: Количество голосов "Нет"numClients: Всего клиентов в пуле голосованияclientInfoSlot: Массив слотов игроков, которые проголосовалиclientInfoItem: Массив выборов голосованияhandler (обратный вызов YesNoVoteHandler) - Функция, вызываемая для событий голосования:
void Handler ( VoteAction action , int param1 , int param2 )
action: Тип события (Start, Vote, End)param1: Зависит от действия (слот клиента для Vote, -1 для End)param2: Зависит от действия (выбор голоса для Vote, VoteEndReason для End)Метод Описание PanoramaIsVoteInProgress()Возвращает true, если голосование в данный момент активно PanoramaEndVote(VoteEndReason reason)Вручную завершить текущее голосование с указанием причины
Метод Описание PanoramaRemovePlayerFromVote(int playerSlot)Удалить игрока из текущего пула голосования PanoramaIsPlayerInVotePool(int playerSlot)Проверить, может ли игрок голосовать в текущем голосовании PanoramaRedrawVoteToClient(int playerSlot)Принудительно обновить UI голосования для игрока
Проверяйте активные голосования - Всегда используйте PanoramaIsVoteInProgress() перед началом нового голосованияРеализуйте пользовательскую логику прохождения - Используйте обратный вызов result для определения, что означает "прохождение" для вашего голосованияОтслеживайте прогресс голосования - Используйте событие VoteAction.Vote обратного вызова handler для мониторинга голосов в реальном времениОбрабатывайте отключения - Удаляйте отключившихся игроков с помощью PanoramaRemovePlayerFromVote()Используйте подходящую длительность - 20-30 секунд типично для большинства голосованийПроверяйте получателей - Убедитесь, что в битовой маске получателей есть хотя бы один игрокИспользуйте строки перевода - Придерживайтесь строк #SFUI_vote или #Panorama_vote для правильной локализацииУдаляйте цели голосования - Не позволяйте игрокам, против которых голосуют, участвоватьРазделяйте логику - Используйте result для логики прохождения/провала, handler для событий и действийЛогируйте данные голосования - Используйте массивы clientInfoSlot и clientInfoItem для детального отслеживания голосовС системой голосования Panorama вы можете создавать увлекательный демократичный игровой опыт, используя нативный UI CS2 для бесшовного взаимодействия с игроками!