Files
vip-custom-models/scripting/vip_custom_models.sp
T
deidara 53d3702c69 v2.0.0: refactor - models assigned by SteamID64 via config
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
2026-05-01 20:53:51 +03:00

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);
}