53d3702c69
Major changes: - Added configs/vip_custom_models_locked.cfg (SteamID64 to model_id) - Command sm_vmodel_reload for hot reload - Removed model selection menu (!vmodel/!models/!agents) - Removed VIP-Core integration (item removed from VIP menu) - Removed ClientPrefs cookies and SQLite (config is single source of truth) Bug fixes: - Fixed null-check bug (error[0] != space instead of null terminator) - Removed DB vs Cookie race condition - Cleaned up buffer null-termination workarounds - author = deidara.dev
516 lines
14 KiB
SourcePawn
516 lines
14 KiB
SourcePawn
#pragma semicolon 1
|
|
#pragma newdecls required
|
|
|
|
#include <sourcemod>
|
|
#include <sdktools>
|
|
|
|
public Plugin myinfo =
|
|
{
|
|
name = "[VIP] Custom Models",
|
|
author = "deidara.dev",
|
|
description = "Кастомные модели игроков по SteamID64 — назначаются через конфиг, без меню",
|
|
version = "2.0.0",
|
|
url = "https://deidara.dev"
|
|
};
|
|
|
|
#define CONFIG_MODELS "configs/vip_custom_models.ini"
|
|
#define CONFIG_LOCKED "configs/vip_custom_models_locked.cfg"
|
|
#define MAX_CUSTOM_MODELS 128
|
|
#define MAX_DOWNLOADS_PER_MODEL 64
|
|
#define TEAM_ANY 0
|
|
#define TEAM_T 2
|
|
#define TEAM_CT 3
|
|
#define DEFAULT_T_ARMS "models/weapons/t_arms_leet.mdl"
|
|
#define DEFAULT_CT_ARMS "models/weapons/ct_arms_fbi.mdl"
|
|
|
|
int g_iModelCount = 0;
|
|
char g_sModelId[MAX_CUSTOM_MODELS][32];
|
|
char g_sModelName[MAX_CUSTOM_MODELS][128];
|
|
char g_sModelPath[MAX_CUSTOM_MODELS][PLATFORM_MAX_PATH];
|
|
char g_sArmsPath[MAX_CUSTOM_MODELS][PLATFORM_MAX_PATH];
|
|
int g_iTeamLimit[MAX_CUSTOM_MODELS];
|
|
int g_iDownloadCount[MAX_CUSTOM_MODELS];
|
|
char g_sDownloads[MAX_CUSTOM_MODELS][MAX_DOWNLOADS_PER_MODEL][PLATFORM_MAX_PATH];
|
|
|
|
// SteamID64 → model_id mapping из locked-конфига
|
|
StringMap g_smLockedAssignments = null;
|
|
|
|
// Per-client кеш индекса назначенной модели (-1 = нет назначения)
|
|
int g_iAssignedModel[MAXPLAYERS + 1];
|
|
|
|
public void OnPluginStart()
|
|
{
|
|
HookEvent("player_spawn", Event_PlayerSpawn, EventHookMode_Post);
|
|
HookEvent("player_team", Event_PlayerTeam, EventHookMode_Post);
|
|
|
|
RegAdminCmd("sm_vmodel_reload", Command_Reload, ADMFLAG_ROOT,
|
|
"Перезагрузить vip_custom_models.ini и vip_custom_models_locked.cfg");
|
|
|
|
g_smLockedAssignments = new StringMap();
|
|
|
|
for (int i = 1; i <= MaxClients; i++)
|
|
{
|
|
g_iAssignedModel[i] = -1;
|
|
}
|
|
}
|
|
|
|
public void OnMapStart()
|
|
{
|
|
LoadModelsConfig();
|
|
LoadLockedAssignments();
|
|
PrecacheAllModels();
|
|
|
|
// Re-resolve назначения для тех кто уже в игре (на случай поздней загрузки плагина)
|
|
for (int i = 1; i <= MaxClients; i++)
|
|
{
|
|
if (IsClientInGame(i) && !IsFakeClient(i))
|
|
{
|
|
ResolveClientAssignment(i);
|
|
}
|
|
}
|
|
}
|
|
|
|
public void OnClientPutInServer(int client)
|
|
{
|
|
g_iAssignedModel[client] = -1;
|
|
}
|
|
|
|
public void OnClientPostAdminCheck(int client)
|
|
{
|
|
if (!IsValidHuman(client))
|
|
{
|
|
return;
|
|
}
|
|
|
|
ResolveClientAssignment(client);
|
|
|
|
// Применяем модель с задержкой — игрок может ещё не быть alive
|
|
CreateTimer(1.5, Timer_ApplyModel, GetClientUserId(client), TIMER_FLAG_NO_MAPCHANGE);
|
|
}
|
|
|
|
public void OnClientDisconnect(int client)
|
|
{
|
|
g_iAssignedModel[client] = -1;
|
|
}
|
|
|
|
void ResolveClientAssignment(int client)
|
|
{
|
|
g_iAssignedModel[client] = -1;
|
|
|
|
if (!IsValidHuman(client) || g_smLockedAssignments == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
char steamid[32];
|
|
if (!GetClientAuthId(client, AuthId_SteamID64, steamid, sizeof(steamid), true))
|
|
{
|
|
return;
|
|
}
|
|
|
|
char modelId[64];
|
|
if (!g_smLockedAssignments.GetString(steamid, modelId, sizeof(modelId)))
|
|
{
|
|
return;
|
|
}
|
|
|
|
int idx = FindModelIndexById(modelId);
|
|
if (idx == -1)
|
|
{
|
|
LogMessage("[VIP Models] Игроку %N (%s) назначена модель '%s', но такой модели нет в %s",
|
|
client, steamid, modelId, CONFIG_MODELS);
|
|
return;
|
|
}
|
|
|
|
g_iAssignedModel[client] = idx;
|
|
}
|
|
|
|
public void Event_PlayerSpawn(Event event, const char[] name, bool dontBroadcast)
|
|
{
|
|
int client = GetClientOfUserId(event.GetInt("userid"));
|
|
if (!IsValidHuman(client))
|
|
{
|
|
return;
|
|
}
|
|
CreateTimer(0.3, Timer_ApplyModel, GetClientUserId(client), TIMER_FLAG_NO_MAPCHANGE);
|
|
}
|
|
|
|
public void Event_PlayerTeam(Event event, const char[] name, bool dontBroadcast)
|
|
{
|
|
int client = GetClientOfUserId(event.GetInt("userid"));
|
|
if (!IsValidHuman(client))
|
|
{
|
|
return;
|
|
}
|
|
CreateTimer(0.3, Timer_ApplyModel, GetClientUserId(client), TIMER_FLAG_NO_MAPCHANGE);
|
|
}
|
|
|
|
public Action Timer_ApplyModel(Handle timer, any userid)
|
|
{
|
|
int client = GetClientOfUserId(userid);
|
|
if (!IsValidHuman(client) || !IsPlayerAlive(client))
|
|
{
|
|
return Plugin_Stop;
|
|
}
|
|
|
|
ApplyAssignedModel(client);
|
|
return Plugin_Stop;
|
|
}
|
|
|
|
void ApplyAssignedModel(int client)
|
|
{
|
|
int idx = g_iAssignedModel[client];
|
|
|
|
// Нет назначения — просто стандартные руки для команды
|
|
if (idx < 0 || idx >= g_iModelCount)
|
|
{
|
|
ApplyDefaultArms(client);
|
|
return;
|
|
}
|
|
|
|
// Проверка ограничения по команде
|
|
int team = GetClientTeam(client);
|
|
if (g_iTeamLimit[idx] != TEAM_ANY && g_iTeamLimit[idx] != team)
|
|
{
|
|
// Кастомная модель не подходит этой команде → дефолт
|
|
ApplyDefaultArms(client);
|
|
return;
|
|
}
|
|
|
|
if (g_sModelPath[idx][0] != '\0')
|
|
{
|
|
SetEntityModel(client, g_sModelPath[idx]);
|
|
}
|
|
|
|
// Руки применяем чуть позже — после установки модели
|
|
CreateTimer(0.1, Timer_ApplyArms, GetClientUserId(client), TIMER_FLAG_NO_MAPCHANGE);
|
|
}
|
|
|
|
public Action Timer_ApplyArms(Handle timer, any userid)
|
|
{
|
|
int client = GetClientOfUserId(userid);
|
|
if (!IsValidHuman(client) || !IsPlayerAlive(client))
|
|
{
|
|
return Plugin_Stop;
|
|
}
|
|
|
|
int idx = g_iAssignedModel[client];
|
|
if (idx >= 0 && idx < g_iModelCount && g_sArmsPath[idx][0] != '\0')
|
|
{
|
|
int team = GetClientTeam(client);
|
|
if (g_iTeamLimit[idx] == TEAM_ANY || g_iTeamLimit[idx] == team)
|
|
{
|
|
SetEntPropString(client, Prop_Send, "m_szArmsModel", g_sArmsPath[idx]);
|
|
PrecacheModel(g_sArmsPath[idx], true);
|
|
return Plugin_Stop;
|
|
}
|
|
}
|
|
|
|
ApplyDefaultArms(client);
|
|
return Plugin_Stop;
|
|
}
|
|
|
|
void ApplyDefaultArms(int client)
|
|
{
|
|
char armsPath[PLATFORM_MAX_PATH];
|
|
GetDefaultArmsForTeam(GetClientTeam(client), armsPath, sizeof(armsPath));
|
|
if (armsPath[0] == '\0')
|
|
{
|
|
return;
|
|
}
|
|
|
|
PrecacheModel(armsPath, true);
|
|
SetEntPropString(client, Prop_Send, "m_szArmsModel", armsPath);
|
|
}
|
|
|
|
void GetDefaultArmsForTeam(int team, char[] buffer, int maxlen)
|
|
{
|
|
buffer[0] = '\0';
|
|
switch (team)
|
|
{
|
|
case TEAM_T: strcopy(buffer, maxlen, DEFAULT_T_ARMS);
|
|
case TEAM_CT: strcopy(buffer, maxlen, DEFAULT_CT_ARMS);
|
|
}
|
|
}
|
|
|
|
public Action Command_Reload(int client, int args)
|
|
{
|
|
LoadModelsConfig();
|
|
LoadLockedAssignments();
|
|
PrecacheAllModels();
|
|
|
|
int reAssigned = 0;
|
|
for (int i = 1; i <= MaxClients; i++)
|
|
{
|
|
if (!IsClientInGame(i) || IsFakeClient(i))
|
|
{
|
|
continue;
|
|
}
|
|
ResolveClientAssignment(i);
|
|
if (g_iAssignedModel[i] >= 0)
|
|
{
|
|
reAssigned++;
|
|
if (IsPlayerAlive(i))
|
|
{
|
|
ApplyAssignedModel(i);
|
|
}
|
|
}
|
|
}
|
|
|
|
ReplyToCommand(client, "[VIP Models] Перезагружено: %d моделей в конфиге, %d игрокам назначены модели.",
|
|
g_iModelCount, reAssigned);
|
|
return Plugin_Handled;
|
|
}
|
|
|
|
void LoadLockedAssignments()
|
|
{
|
|
g_smLockedAssignments.Clear();
|
|
|
|
char path[PLATFORM_MAX_PATH];
|
|
BuildPath(Path_SM, path, sizeof(path), CONFIG_LOCKED);
|
|
|
|
KeyValues kv = new KeyValues("VIP_LockedModels");
|
|
if (!kv.ImportFromFile(path))
|
|
{
|
|
LogMessage("[VIP Models] Файл %s не найден — никаких назначений не загружено.", path);
|
|
delete kv;
|
|
return;
|
|
}
|
|
|
|
if (!kv.GotoFirstSubKey(false))
|
|
{
|
|
delete kv;
|
|
LogMessage("[VIP Models] Файл %s пуст.", path);
|
|
return;
|
|
}
|
|
|
|
int count = 0;
|
|
char steamid[32], modelId[64];
|
|
do
|
|
{
|
|
kv.GetSectionName(steamid, sizeof(steamid));
|
|
kv.GetString(NULL_STRING, modelId, sizeof(modelId), "");
|
|
TrimString(steamid);
|
|
TrimString(modelId);
|
|
|
|
if (steamid[0] != '\0' && modelId[0] != '\0')
|
|
{
|
|
g_smLockedAssignments.SetString(steamid, modelId);
|
|
count++;
|
|
}
|
|
}
|
|
while (kv.GotoNextKey(false));
|
|
|
|
delete kv;
|
|
LogMessage("[VIP Models] Загружено %d назначений из %s", count, path);
|
|
}
|
|
|
|
void LoadModelsConfig()
|
|
{
|
|
g_iModelCount = 0;
|
|
|
|
char path[PLATFORM_MAX_PATH];
|
|
BuildPath(Path_SM, path, sizeof(path), CONFIG_MODELS);
|
|
|
|
KeyValues kv = new KeyValues("VIP_CustomModels");
|
|
if (!kv.ImportFromFile(path))
|
|
{
|
|
LogError("[VIP Models] Не удалось загрузить %s", path);
|
|
delete kv;
|
|
return;
|
|
}
|
|
|
|
if (!kv.JumpToKey("Models"))
|
|
{
|
|
LogError("[VIP Models] Секция 'Models' не найдена в %s", path);
|
|
delete kv;
|
|
return;
|
|
}
|
|
|
|
if (!kv.GotoFirstSubKey())
|
|
{
|
|
delete kv;
|
|
return;
|
|
}
|
|
|
|
do
|
|
{
|
|
if (g_iModelCount >= MAX_CUSTOM_MODELS)
|
|
{
|
|
break;
|
|
}
|
|
|
|
kv.GetSectionName(g_sModelId[g_iModelCount], sizeof(g_sModelId[]));
|
|
kv.GetString("name", g_sModelName[g_iModelCount], sizeof(g_sModelName[]), g_sModelId[g_iModelCount]);
|
|
kv.GetString("model", g_sModelPath[g_iModelCount], sizeof(g_sModelPath[]), "");
|
|
kv.GetString("arms", g_sArmsPath[g_iModelCount], sizeof(g_sArmsPath[]), "");
|
|
g_iDownloadCount[g_iModelCount] = 0;
|
|
|
|
char team[8];
|
|
kv.GetString("team", team, sizeof(team), "ANY");
|
|
if (StrEqual(team, "T", false)) g_iTeamLimit[g_iModelCount] = TEAM_T;
|
|
else if (StrEqual(team, "CT", false)) g_iTeamLimit[g_iModelCount] = TEAM_CT;
|
|
else g_iTeamLimit[g_iModelCount] = TEAM_ANY;
|
|
|
|
ParseDownloadsForCurrentModel(kv, g_iModelCount);
|
|
|
|
if (g_sModelPath[g_iModelCount][0] != '\0')
|
|
{
|
|
g_iModelCount++;
|
|
}
|
|
}
|
|
while (kv.GotoNextKey());
|
|
|
|
delete kv;
|
|
LogMessage("[VIP Models] Загружено %d моделей из %s", g_iModelCount, path);
|
|
}
|
|
|
|
void ParseDownloadsForCurrentModel(KeyValues kv, int modelIdx)
|
|
{
|
|
char buffer[2048];
|
|
kv.GetString("downloads", buffer, sizeof(buffer), "");
|
|
|
|
// Формат-строка: "file1;file2;file3"
|
|
if (buffer[0] != '\0')
|
|
{
|
|
char parts[128][PLATFORM_MAX_PATH];
|
|
int count = ExplodeString(buffer, ";", parts, sizeof(parts), sizeof(parts[]));
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
TrimString(parts[i]);
|
|
if (parts[i][0] == '\0')
|
|
{
|
|
continue;
|
|
}
|
|
AddDownloadPathToModel(modelIdx, parts[i]);
|
|
}
|
|
}
|
|
|
|
// Блочный формат: "downloads" { "1" "path" ... }
|
|
if (kv.JumpToKey("downloads"))
|
|
{
|
|
if (kv.GotoFirstSubKey(false))
|
|
{
|
|
do
|
|
{
|
|
char value[PLATFORM_MAX_PATH];
|
|
kv.GetString(NULL_STRING, value, sizeof(value), "");
|
|
TrimString(value);
|
|
if (value[0] != '\0')
|
|
{
|
|
AddDownloadPathToModel(modelIdx, value);
|
|
}
|
|
}
|
|
while (kv.GotoNextKey(false));
|
|
kv.GoBack();
|
|
}
|
|
kv.GoBack();
|
|
}
|
|
}
|
|
|
|
void AddDownloadPathToModel(int modelIdx, const char[] path)
|
|
{
|
|
if (modelIdx < 0 || modelIdx >= MAX_CUSTOM_MODELS)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (g_iDownloadCount[modelIdx] >= MAX_DOWNLOADS_PER_MODEL)
|
|
{
|
|
LogError("[VIP Models] Слишком много downloads для модели %s", g_sModelId[modelIdx]);
|
|
return;
|
|
}
|
|
|
|
// Без дубликатов
|
|
for (int i = 0; i < g_iDownloadCount[modelIdx]; i++)
|
|
{
|
|
if (StrEqual(g_sDownloads[modelIdx][i], path, false))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
strcopy(g_sDownloads[modelIdx][g_iDownloadCount[modelIdx]], sizeof(g_sDownloads[][]), path);
|
|
g_iDownloadCount[modelIdx]++;
|
|
}
|
|
|
|
void PrecacheAllModels()
|
|
{
|
|
PrecacheModel(DEFAULT_T_ARMS, true);
|
|
PrecacheModel(DEFAULT_CT_ARMS, true);
|
|
|
|
for (int i = 0; i < g_iModelCount; i++)
|
|
{
|
|
AddModelFiles(g_sModelPath[i]);
|
|
PrecacheModel(g_sModelPath[i], true);
|
|
|
|
if (g_sArmsPath[i][0] != '\0')
|
|
{
|
|
AddModelFiles(g_sArmsPath[i]);
|
|
PrecacheModel(g_sArmsPath[i], true);
|
|
}
|
|
|
|
for (int x = 0; x < g_iDownloadCount[i]; x++)
|
|
{
|
|
AddIfExists(g_sDownloads[i][x]);
|
|
}
|
|
}
|
|
}
|
|
|
|
void AddModelFiles(const char[] mdlPath)
|
|
{
|
|
if (mdlPath[0] == '\0')
|
|
{
|
|
return;
|
|
}
|
|
|
|
AddIfExists(mdlPath);
|
|
|
|
char base[PLATFORM_MAX_PATH];
|
|
strcopy(base, sizeof(base), mdlPath);
|
|
ReplaceString(base, sizeof(base), ".mdl", "", false);
|
|
|
|
char buffer[PLATFORM_MAX_PATH];
|
|
|
|
FormatEx(buffer, sizeof(buffer), "%s.vvd", base); AddIfExists(buffer);
|
|
FormatEx(buffer, sizeof(buffer), "%s.dx90.vtx", base); AddIfExists(buffer);
|
|
FormatEx(buffer, sizeof(buffer), "%s.dx80.vtx", base); AddIfExists(buffer, false);
|
|
FormatEx(buffer, sizeof(buffer), "%s.vtx", base); AddIfExists(buffer, false);
|
|
FormatEx(buffer, sizeof(buffer), "%s.sw.vtx", base); AddIfExists(buffer, false);
|
|
FormatEx(buffer, sizeof(buffer), "%s.phy", base); AddIfExists(buffer, false);
|
|
}
|
|
|
|
void AddIfExists(const char[] relPath, bool logMissing = false)
|
|
{
|
|
if (relPath[0] == '\0')
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (FileExists(relPath, true))
|
|
{
|
|
AddFileToDownloadsTable(relPath);
|
|
}
|
|
else if (logMissing)
|
|
{
|
|
LogMessage("[VIP Models] Файл для загрузки не найден: %s", relPath);
|
|
}
|
|
}
|
|
|
|
int FindModelIndexById(const char[] id)
|
|
{
|
|
for (int i = 0; i < g_iModelCount; i++)
|
|
{
|
|
if (StrEqual(g_sModelId[i], id, false))
|
|
{
|
|
return i;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
bool IsValidHuman(int client)
|
|
{
|
|
return client > 0 && client <= MaxClients && IsClientInGame(client) && !IsFakeClient(client);
|
|
}
|