Files
sm-plugins/Leader3/scripting/leader3/markers.sp
T
2026-10-22 16:17:56 +01:00

627 lines
16 KiB
SourcePawn

//----------------------------------------------------------------------------------------------------
// Leader3 markers: unloze_ping prop_dynamic markers with point_worldtext titles.
// The ArrayList is the authoritative store; entities are respawned from it every round.
//----------------------------------------------------------------------------------------------------
enum struct Marker
{
int ownerUserId;
char ownerName[MAX_NAME_LENGTH];
int skin;
float pos[3];
char title[128];
float placedAt;
int propRef;
int textRef;
}
ArrayList g_alMarkers;
void Markers_OnPluginStart()
{
g_alMarkers = new ArrayList(sizeof(Marker));
}
void SkinName(int skin, char[] buf, int maxlen)
{
switch (skin)
{
case SKIN_SKULL: strcopy(buf, maxlen, "Skull");
case SKIN_MOON: strcopy(buf, maxlen, "Moon");
case SKIN_CROSS: strcopy(buf, maxlen, "Cross");
default: strcopy(buf, maxlen, "Star");
}
}
int SkinFromName(const char[] name)
{
if (StrEqual(name, "star", false)) return SKIN_STAR;
if (StrEqual(name, "skull", false)) return SKIN_SKULL;
if (StrEqual(name, "moon", false)) return SKIN_MOON;
if (StrEqual(name, "cross", false)) return SKIN_CROSS;
return -1;
}
//----------------------------------------------------------------------------------------------------
// Placement
//----------------------------------------------------------------------------------------------------
void Marker_Place(int client, int skin)
{
if (!IsLeader(client))
return;
// Dead and spectating leaders may keep placing markers (leading from spec);
// only living zombies are blocked.
if (IsLivingZombie(client))
{
PrintToChat(client, "%sYou cannot place markers as a zombie.", PREFIX);
return;
}
float pos[3];
if (!TracePlayerAngles(client, pos))
{
PrintToChat(client, "%sCould not find a spot to place the marker on.", PREFIX);
return;
}
char skinName[16];
SkinName(skin, skinName, sizeof(skinName));
int userid = GetClientUserId(client);
int existing = FindMarkerByOwnerSkin(userid, skin);
Marker marker;
if (existing != -1)
{
// Moving keeps the title and stays out of chat. The prop and text entities are
// teleported rather than respawned so repeated moves don't churn edicts.
g_alMarkers.GetArray(existing, marker);
marker.pos = pos;
marker.placedAt = GetGameTime();
GetClientName(client, marker.ownerName, sizeof(marker.ownerName));
g_alMarkers.SetArray(existing, marker);
Marker_MoveProp(existing);
Marker_UpdateText(existing);
}
else
{
if (CountMarkersByOwner(userid) >= g_cvMarkerLimit.IntValue)
{
PrintToChat(client, "%sMarker limit reached (%d). Replace one of your markers or use \x04!clearmarkers\x01.", PREFIX, g_cvMarkerLimit.IntValue);
return;
}
marker.ownerUserId = userid;
GetClientName(client, marker.ownerName, sizeof(marker.ownerName));
marker.skin = skin;
marker.pos = pos;
marker.title[0] = '\0';
marker.placedAt = GetGameTime();
marker.propRef = INVALID_ENT_REFERENCE;
marker.textRef = INVALID_ENT_REFERENCE;
int idx = g_alMarkers.PushArray(marker);
SpawnMarkerProp(idx);
PrintToChatAll("%s\x04%N\x01 placed a \x04%s\x01 marker.", PREFIX, client, skinName);
PrintToChat(client, "%sType \x04`your text\x01 in chat to put a title on it.", PREFIX);
}
}
void Marker_PlaceBySpray(int client)
{
Marker_Place(client, SKIN_STAR);
}
int FindMarkerByOwnerSkin(int userid, int skin)
{
Marker marker;
for (int i = 0; i < g_alMarkers.Length; i++)
{
g_alMarkers.GetArray(i, marker);
if (marker.ownerUserId == userid && marker.skin == skin)
return i;
}
return -1;
}
bool Marker_GetTitle(int client, int skin, char[] buf, int maxlen)
{
int idx = FindMarkerByOwnerSkin(GetClientUserId(client), skin);
if (idx == -1)
return false;
Marker marker;
g_alMarkers.GetArray(idx, marker);
if (marker.title[0] == '\0')
return false;
strcopy(buf, maxlen, marker.title);
return true;
}
int CountMarkersByOwner(int userid)
{
int count = 0;
Marker marker;
for (int i = 0; i < g_alMarkers.Length; i++)
{
g_alMarkers.GetArray(i, marker);
if (marker.ownerUserId == userid)
count++;
}
return count;
}
//----------------------------------------------------------------------------------------------------
// Titles
//----------------------------------------------------------------------------------------------------
// Titles typed entirely in lowercase are uppercased; mixed-case titles are kept as typed.
void UppercaseAllLowerTitle(char[] text)
{
bool hasLower = false;
for (int i = 0; text[i] != '\0'; i++)
{
int bytes = IsCharMB(text[i]);
if (bytes > 1)
{
i += bytes - 1;
continue;
}
if (IsCharUpper(text[i]))
return;
if (IsCharLower(text[i]))
hasLower = true;
}
if (!hasLower)
return;
for (int i = 0; text[i] != '\0'; i++)
{
int bytes = IsCharMB(text[i]);
if (bytes > 1)
{
i += bytes - 1;
continue;
}
text[i] = CharToUpper(text[i]);
}
}
void Marker_SetTitle(int client, const char[] text)
{
int userid = GetClientUserId(client);
// Most recently placed marker owned by this leader.
int lastIdx = -1;
float lastTime = -1.0;
Marker marker;
for (int i = 0; i < g_alMarkers.Length; i++)
{
g_alMarkers.GetArray(i, marker);
if (marker.ownerUserId == userid && marker.placedAt > lastTime)
{
lastTime = marker.placedAt;
lastIdx = i;
}
}
if (lastIdx == -1)
{
PrintToChat(client, "%sYou have no marker to title. Place one first.", PREFIX);
return;
}
g_alMarkers.GetArray(lastIdx, marker);
strcopy(marker.title, sizeof(marker.title), text);
UppercaseAllLowerTitle(marker.title);
g_alMarkers.SetArray(lastIdx, marker);
Marker_UpdateText(lastIdx);
char skinName[16];
SkinName(marker.skin, skinName, sizeof(skinName));
if (text[0] == '\0')
{
PrintToChat(client, "%sTitle removed from your \x04%s\x01 marker.", PREFIX, skinName);
return;
}
PrintToChatAll("%s\x04%N\x01 marked \x04%s\x01: \x04%s", PREFIX, client, skinName, marker.title);
}
//----------------------------------------------------------------------------------------------------
// Entity lifecycle
//----------------------------------------------------------------------------------------------------
void SpawnMarkerProp(int idx)
{
Marker marker;
g_alMarkers.GetArray(idx, marker);
int ent = CreateEntityByName("prop_dynamic");
if (ent == -1)
return;
SetEntityModel(ent, MARKER_MODEL);
DispatchKeyValueFloat(ent, "modelscale", MARKER_MODEL_SCALE);
char skin[4];
IntToString(marker.skin, skin, sizeof(skin));
SetVariantString(skin);
AcceptEntityInput(ent, "Skin");
DispatchSpawn(ent);
TeleportEntity(ent, marker.pos, NULL_VECTOR, NULL_VECTOR);
marker.propRef = EntIndexToEntRef(ent);
g_alMarkers.SetArray(idx, marker);
}
void SpawnMarkerText(int idx)
{
Marker marker;
g_alMarkers.GetArray(idx, marker);
if (marker.title[0] == '\0' || g_bWorldTextBroken)
return;
int ent = CreateEntityByName("point_worldtext");
if (ent == -1)
{
// Engine build without point_worldtext: titles stay chat-only for this map.
g_bWorldTextBroken = true;
LogMessage("point_worldtext could not be created; marker titles will not render in the world.");
return;
}
char text[192];
Format(text, sizeof(text), "%s", marker.title);
DispatchKeyValue(ent, "message", text);
DispatchKeyValue(ent, "font", "10");
DispatchKeyValue(ent, "textsize", "12");
DispatchKeyValue(ent, "color", "255 255 255");
DispatchKeyValue(ent, "orientation", "1");
DispatchKeyValue(ent, "rainbow", "1");
DispatchSpawn(ent);
float pos[3];
ComputeMarkerTextPos(marker, pos);
TeleportEntity(ent, pos, NULL_VECTOR, NULL_VECTOR);
marker.textRef = EntIndexToEntRef(ent);
g_alMarkers.SetArray(idx, marker);
}
void ComputeMarkerTextPos(const Marker marker, float pos[3])
{
pos = marker.pos;
// unloze_ping.mdl is 80 units tall and spawned at modelscale 1.5 (120 units).
pos[2] += MARKER_MODEL_HEIGHT * MARKER_MODEL_SCALE + 8.0;
// Markers placed in low rooms would put the text inside the ceiling: trace up
// from the marker base and keep the text MARKER_TEXT_CLEARANCE below any hit.
float up[3];
up = marker.pos;
up[2] = pos[2] + MARKER_TEXT_CLEARANCE;
Handle trace = TR_TraceRayFilterEx(marker.pos, up, MASK_SOLID_BRUSHONLY, RayType_EndPoint, TraceFilter_NoPlayers);
if (TR_DidHit(trace))
{
float hit[3];
TR_GetEndPosition(hit, trace);
pos[2] = hit[2] - MARKER_TEXT_CLEARANCE;
}
delete trace;
}
// Teleports the existing marker prop to the stored position, spawning one only if the
// reference went stale.
void Marker_MoveProp(int idx)
{
Marker marker;
g_alMarkers.GetArray(idx, marker);
int ent = LiveEntRef(marker.propRef, "prop_dynamic");
if (ent == -1)
{
SpawnMarkerProp(idx);
return;
}
TeleportEntity(ent, marker.pos, NULL_VECTOR, NULL_VECTOR);
}
// Retitles/moves the existing point_worldtext in place (SetText is networked via
// SendPropString) instead of respawning it, so retitles and moves don't churn edicts.
void Marker_UpdateText(int idx)
{
Marker marker;
g_alMarkers.GetArray(idx, marker);
if (marker.title[0] == '\0')
{
KillEntRef(marker.textRef, "point_worldtext");
marker.textRef = INVALID_ENT_REFERENCE;
g_alMarkers.SetArray(idx, marker);
return;
}
int ent = LiveEntRef(marker.textRef, "point_worldtext");
if (ent == -1)
{
SpawnMarkerText(idx);
return;
}
SetVariantString(marker.title);
AcceptEntityInput(ent, "SetText");
float pos[3];
ComputeMarkerTextPos(marker, pos);
TeleportEntity(ent, pos, NULL_VECTOR, NULL_VECTOR);
}
void Marker_KillEnts(Marker marker)
{
KillEntRef(marker.propRef, "prop_dynamic");
KillEntRef(marker.textRef, "point_worldtext");
}
void KillEntRef(int ref, const char[] expectedClass)
{
int ent = LiveEntRef(ref, expectedClass);
if (ent != -1)
AcceptEntityInput(ent, "Kill");
}
// Resolves an entity reference, returning -1 unless it is still a live edict of the
// expected class.
int LiveEntRef(int ref, const char[] expectedClass)
{
if (ref == INVALID_ENT_REFERENCE)
return -1;
int ent = EntRefToEntIndex(ref);
if (ent == INVALID_ENT_REFERENCE || !IsValidEdict(ent))
return -1;
char classname[64];
GetEdictClassname(ent, classname, sizeof(classname));
return strcmp(classname, expectedClass) == 0 ? ent : -1;
}
// Round transitions delete runtime entities: rebuild every marker from the stored data.
void Markers_OnRoundStart()
{
Marker marker;
for (int i = 0; i < g_alMarkers.Length; i++)
{
g_alMarkers.GetArray(i, marker);
marker.propRef = INVALID_ENT_REFERENCE;
marker.textRef = INVALID_ENT_REFERENCE;
g_alMarkers.SetArray(i, marker);
SpawnMarkerProp(i);
SpawnMarkerText(i);
}
}
void Markers_ClearAll(bool killEnts = true)
{
if (killEnts)
{
Marker marker;
for (int i = 0; i < g_alMarkers.Length; i++)
{
g_alMarkers.GetArray(i, marker);
Marker_KillEnts(marker);
}
}
g_alMarkers.Clear();
}
void Marker_ClearByOwner(int client, bool announce = true)
{
int userid = GetClientUserId(client);
int removed = 0;
Marker marker;
for (int i = g_alMarkers.Length - 1; i >= 0; i--)
{
g_alMarkers.GetArray(i, marker);
if (marker.ownerUserId != userid)
continue;
Marker_KillEnts(marker);
g_alMarkers.Erase(i);
removed++;
}
if (!announce)
return;
if (removed > 0)
PrintToChat(client, "%sRemoved %d of your markers.", PREFIX, removed);
else
PrintToChat(client, "%sYou have no markers.", PREFIX);
}
//----------------------------------------------------------------------------------------------------
// Commands
//----------------------------------------------------------------------------------------------------
public Action Cmd_Marker(int client, int args)
{
if (client == 0)
{
ReplyToCommand(client, "%s Cannot use this from the console.", PREFIX_PLAIN);
return Plugin_Handled;
}
if (!IsLeader(client))
{
ReplyToCommand(client, "%s Only leaders can place markers.", PREFIX_PLAIN);
return Plugin_Handled;
}
if (args < 1)
{
ReplyToCommand(client, "%s Usage: sm_marker <star|skull|moon|cross>", PREFIX_PLAIN);
return Plugin_Handled;
}
char arg[16];
GetCmdArg(1, arg, sizeof(arg));
int skin = SkinFromName(arg);
if (skin == -1)
{
ReplyToCommand(client, "%s Unknown marker type '%s'. Use star, skull, moon or cross.", PREFIX_PLAIN, arg);
return Plugin_Handled;
}
Marker_Place(client, skin);
return Plugin_Handled;
}
public Action Cmd_MarkerTitle(int client, int args)
{
if (client == 0)
{
ReplyToCommand(client, "%s Cannot use this from the console.", PREFIX_PLAIN);
return Plugin_Handled;
}
if (!IsLeader(client))
{
ReplyToCommand(client, "%s Only leaders can title markers.", PREFIX_PLAIN);
return Plugin_Handled;
}
char text[128];
GetCmdArgString(text, sizeof(text));
StripQuotes(text);
TrimString(text);
Marker_SetTitle(client, text);
return Plugin_Handled;
}
public Action Cmd_ClearMarkers(int client, int args)
{
if (client == 0)
{
Markers_ClearAll();
ReplyToCommand(client, "%s All markers cleared.", PREFIX_PLAIN);
return Plugin_Handled;
}
if (args >= 1 && CheckCommandAccess(client, "sm_admin", ADMFLAG_GENERIC, false))
{
char arg[65];
GetCmdArg(1, arg, sizeof(arg));
if (StrEqual(arg, "all", false))
{
Markers_ClearAll();
PrintToChatAll("%sAll markers have been cleared by \x04%N\x01.", PREFIX, client);
return Plugin_Handled;
}
char targetName[MAX_TARGET_LENGTH];
int targets[MAXPLAYERS];
bool tnIsMl;
int count = ProcessTargetString(arg, client, targets, sizeof(targets),
COMMAND_FILTER_CONNECTED | COMMAND_FILTER_NO_BOTS, targetName, sizeof(targetName), tnIsMl);
if (count <= 0)
{
ReplyToTargetError(client, count);
return Plugin_Handled;
}
for (int i = 0; i < count; i++)
Marker_ClearByOwner(targets[i]);
ReplyToCommand(client, "%s Cleared markers of %s.", PREFIX_PLAIN, targetName);
return Plugin_Handled;
}
if (!IsLeader(client))
{
ReplyToCommand(client, "%s Only leaders can clear markers.", PREFIX_PLAIN);
return Plugin_Handled;
}
Marker_ClearByOwner(client);
return Plugin_Handled;
}
//----------------------------------------------------------------------------------------------------
// Aim trace (port of Leader2's TracePlayerAngles)
//----------------------------------------------------------------------------------------------------
bool TracePlayerAngles(int client, float vecResult[3])
{
if (!IsClientInGame(client))
return false;
float vecEyeAngles[3], vecEyeOrigin[3];
GetClientEyeAngles(client, vecEyeAngles);
GetClientEyePosition(client, vecEyeOrigin);
Handle trace = TR_TraceRayFilterEx(vecEyeOrigin, vecEyeAngles, MASK_SHOT_HULL, RayType_Infinite, TraceFilter_NoPlayers);
// AllSolid: eyes clipped inside a solid (crowd pushes); endpos degenerates to the eye.
bool hit = TR_DidHit(trace) && !TR_AllSolid(trace);
int hitEnt = hit ? TR_GetEntityIndex(trace) : -1;
if (hit)
TR_GetEndPosition(vecResult, trace);
delete trace;
if (!hit)
return false;
// NaN or out-of-grid components reach the client as 0 (WriteBitCoord encodes them
// as zero), which draws the beam to the world origin. Catch them before the TE.
if (vecResult[0] != vecResult[0] || vecResult[1] != vecResult[1] || vecResult[2] != vecResult[2]
|| FloatAbs(vecResult[0]) > 16384.0 || FloatAbs(vecResult[1]) > 16384.0 || FloatAbs(vecResult[2]) > 16384.0)
{
LogSuspectTrace(client, hitEnt, vecResult, vecEyeOrigin, vecEyeAngles, "insane endpos");
return false;
}
// ZE maps park "removed" but still solid props at the world origin; their hulls
// catch the ray there. Hits on the world itself near origin stay legitimate.
if (hitEnt > 0 && GetVectorLength(vecResult) < 64.0)
{
LogSuspectTrace(client, hitEnt, vecResult, vecEyeOrigin, vecEyeAngles, "entity hull at world origin");
return false;
}
return true;
}
// One line per 5s so a leader holding the tracer on a culprit doesn't flood the log.
static float g_fNextSuspectTraceLog;
static void LogSuspectTrace(int client, int ent, const float end[3], const float eye[3], const float ang[3], const char[] why)
{
float now = GetGameTime();
if (now < g_fNextSuspectTraceLog)
return;
g_fNextSuspectTraceLog = now + 5.0;
char classname[64] = "worldspawn";
char model[PLATFORM_MAX_PATH];
if (ent > 0 && IsValidEdict(ent))
{
GetEdictClassname(ent, classname, sizeof(classname));
GetEntPropString(ent, Prop_Data, "m_ModelName", model, sizeof(model));
}
LogMessage("Aim trace rejected (%s) for %L: end (%.1f %.1f %.1f), hit ent %d (%s, model '%s'), eye (%.1f %.1f %.1f), ang (%.1f %.1f %.1f)",
why, client, end[0], end[1], end[2], ent, classname, model,
eye[0], eye[1], eye[2], ang[0], ang[1], ang[2]);
}
public bool TraceFilter_NoPlayers(int entity, int contentsMask)
{
return entity > MaxClients;
}