summaryrefslogtreecommitdiff
path: root/sourcemod/scripting/gokz-core/map
diff options
context:
space:
mode:
Diffstat (limited to 'sourcemod/scripting/gokz-core/map')
-rw-r--r--sourcemod/scripting/gokz-core/map/buttons.sp138
-rw-r--r--sourcemod/scripting/gokz-core/map/end.sp155
-rw-r--r--sourcemod/scripting/gokz-core/map/mapfile.sp502
-rw-r--r--sourcemod/scripting/gokz-core/map/prefix.sp48
-rw-r--r--sourcemod/scripting/gokz-core/map/starts.sp219
-rw-r--r--sourcemod/scripting/gokz-core/map/triggers.sp855
-rw-r--r--sourcemod/scripting/gokz-core/map/zones.sp183
7 files changed, 2100 insertions, 0 deletions
diff --git a/sourcemod/scripting/gokz-core/map/buttons.sp b/sourcemod/scripting/gokz-core/map/buttons.sp
new file mode 100644
index 0000000..8923fbd
--- /dev/null
+++ b/sourcemod/scripting/gokz-core/map/buttons.sp
@@ -0,0 +1,138 @@
+/*
+ Hooks between specifically named func_buttons and GOKZ.
+*/
+
+
+
+static Regex RE_BonusStartButton;
+static Regex RE_BonusEndButton;
+
+
+
+// =====[ EVENTS ]=====
+
+void OnPluginStart_MapButtons()
+{
+ RE_BonusStartButton = CompileRegex(GOKZ_BONUS_START_BUTTON_NAME_REGEX);
+ RE_BonusEndButton = CompileRegex(GOKZ_BONUS_END_BUTTON_NAME_REGEX);
+}
+
+void OnEntitySpawned_MapButtons(int entity)
+{
+ char buffer[32];
+
+ GetEntityClassname(entity, buffer, sizeof(buffer));
+ if (!StrEqual("func_button", buffer, false))
+ {
+ return;
+ }
+
+ if (GetEntityName(entity, buffer, sizeof(buffer)) == 0)
+ {
+ return;
+ }
+
+ int course = 0;
+ if (StrEqual(GOKZ_START_BUTTON_NAME, buffer, false))
+ {
+ HookSingleEntityOutput(entity, "OnPressed", OnStartButtonPress);
+ RegisterCourseStart(course);
+ }
+ else if (StrEqual(GOKZ_END_BUTTON_NAME, buffer, false))
+ {
+ HookSingleEntityOutput(entity, "OnPressed", OnEndButtonPress);
+ RegisterCourseEnd(course);
+ }
+ else if ((course = GetStartButtonBonusNumber(entity)) != -1)
+ {
+ HookSingleEntityOutput(entity, "OnPressed", OnBonusStartButtonPress);
+ RegisterCourseStart(course);
+ }
+ else if ((course = GetEndButtonBonusNumber(entity)) != -1)
+ {
+ HookSingleEntityOutput(entity, "OnPressed", OnBonusEndButtonPress);
+ RegisterCourseEnd(course);
+ }
+}
+
+public void OnStartButtonPress(const char[] name, int caller, int activator, float delay)
+{
+ if (!IsValidEntity(caller) || !IsValidClient(activator))
+ {
+ return;
+ }
+
+ ProcessStartButtonPress(activator, 0);
+}
+
+public void OnEndButtonPress(const char[] name, int caller, int activator, float delay)
+{
+ if (!IsValidEntity(caller) || !IsValidClient(activator))
+ {
+ return;
+ }
+
+ ProcessEndButtonPress(activator, 0);
+}
+
+public void OnBonusStartButtonPress(const char[] name, int caller, int activator, float delay)
+{
+ if (!IsValidEntity(caller) || !IsValidClient(activator))
+ {
+ return;
+ }
+
+ int course = GetStartButtonBonusNumber(caller);
+ if (!GOKZ_IsValidCourse(course, true))
+ {
+ return;
+ }
+
+ ProcessStartButtonPress(activator, course);
+}
+
+public void OnBonusEndButtonPress(const char[] name, int caller, int activator, float delay)
+{
+ if (!IsValidEntity(caller) || !IsValidClient(activator))
+ {
+ return;
+ }
+
+ int course = GetEndButtonBonusNumber(caller);
+ if (!GOKZ_IsValidCourse(course, true))
+ {
+ return;
+ }
+
+ ProcessEndButtonPress(activator, course);
+}
+
+
+
+// =====[ PRIVATE ]=====
+
+static void ProcessStartButtonPress(int client, int course)
+{
+ if (GOKZ_StartTimer(client, course))
+ {
+ // Only calling on success is intended behaviour (and prevents virtual button exploits)
+ OnStartButtonPress_Teleports(client, course);
+ OnStartButtonPress_VirtualButtons(client, course);
+ }
+}
+
+static void ProcessEndButtonPress(int client, int course)
+{
+ GOKZ_EndTimer(client, course);
+ OnEndButtonPress_VirtualButtons(client, course);
+}
+
+static int GetStartButtonBonusNumber(int entity)
+{
+ return GOKZ_MatchIntFromEntityName(entity, RE_BonusStartButton, 1);
+}
+
+static int GetEndButtonBonusNumber(int entity)
+{
+ return GOKZ_MatchIntFromEntityName(entity, RE_BonusEndButton, 1);
+} \ No newline at end of file
diff --git a/sourcemod/scripting/gokz-core/map/end.sp b/sourcemod/scripting/gokz-core/map/end.sp
new file mode 100644
index 0000000..d119084
--- /dev/null
+++ b/sourcemod/scripting/gokz-core/map/end.sp
@@ -0,0 +1,155 @@
+/*
+ Hooks between specifically named end destinations and GOKZ
+*/
+
+
+
+static Regex RE_BonusEndButton;
+static Regex RE_BonusEndZone;
+static CourseTimerType endType[GOKZ_MAX_COURSES];
+static float endOrigin[GOKZ_MAX_COURSES][3];
+static float endAngles[GOKZ_MAX_COURSES][3];
+
+
+
+// =====[ EVENTS ]=====
+
+void OnPluginStart_MapEnd()
+{
+ RE_BonusEndButton = CompileRegex(GOKZ_BONUS_END_BUTTON_NAME_REGEX);
+ RE_BonusEndZone = CompileRegex(GOKZ_BONUS_END_ZONE_NAME_REGEX);
+}
+
+void OnEntitySpawnedPost_MapEnd(int entity)
+{
+ char buffer[32];
+
+ GetEntityClassname(entity, buffer, sizeof(buffer));
+
+ if (StrEqual("trigger_multiple", buffer, false))
+ {
+ bool isEndZone;
+ if (GetEntityName(entity, buffer, sizeof(buffer)) != 0)
+ {
+ if (StrEqual(GOKZ_END_ZONE_NAME, buffer, false))
+ {
+ isEndZone = true;
+ StoreEnd(0, entity, CourseTimerType_ZoneNew);
+ }
+ else if (GetEndZoneBonusNumber(entity) != -1)
+ {
+ int course = GetEndZoneBonusNumber(entity);
+ if (GOKZ_IsValidCourse(course, true))
+ {
+ isEndZone = true;
+ StoreEnd(course, entity, CourseTimerType_ZoneNew);
+ }
+ }
+ }
+ if (!isEndZone)
+ {
+ TimerButtonTrigger trigger;
+ if (IsTimerButtonTrigger(entity, trigger) && !trigger.isStartTimer)
+ {
+ StoreEnd(trigger.course, entity, CourseTimerType_ZoneLegacy);
+ }
+ }
+ }
+ else if (StrEqual("func_button", buffer, false))
+ {
+ bool isEndButton;
+ if (GetEntityName(entity, buffer, sizeof(buffer)) != 0)
+ {
+ if (StrEqual(GOKZ_END_BUTTON_NAME, buffer, false))
+ {
+ isEndButton = true;
+ StoreEnd(0, entity, CourseTimerType_Button);
+ }
+ else
+ {
+ int course = GetEndButtonBonusNumber(entity);
+ if (GOKZ_IsValidCourse(course, true))
+ {
+ isEndButton = true;
+ StoreEnd(course, entity, CourseTimerType_Button);
+ }
+ }
+ }
+ if (!isEndButton)
+ {
+ TimerButtonTrigger trigger;
+ if (IsTimerButtonTrigger(entity, trigger) && !trigger.isStartTimer)
+ {
+ StoreEnd(trigger.course, entity, CourseTimerType_Button);
+ }
+ }
+ }
+}
+
+void OnMapStart_MapEnd()
+{
+ for (int course = 0; course < GOKZ_MAX_COURSES; course++)
+ {
+ endType[course] = CourseTimerType_None;
+ }
+}
+
+bool GetMapEndPosition(int course, float origin[3], float angles[3])
+{
+ if (endType[course] == CourseTimerType_None)
+ {
+ return false;
+ }
+
+ origin = endOrigin[course];
+ angles = endAngles[course];
+
+ return true;
+}
+
+
+
+// =====[ PRIVATE ]=====
+
+static void StoreEnd(int course, int entity, CourseTimerType type)
+{
+ // If StoreEnd is called, then there is at least an end position (even though it might not be a valid one)
+ if (endType[course] < CourseTimerType_Default)
+ {
+ endType[course] = CourseTimerType_Default;
+ }
+
+ // Real zone is always better than "fake" zones which are better than buttons
+ // as the buttons found in a map with fake zones aren't meant to be visible.
+ if (endType[course] >= type)
+ {
+ return;
+ }
+
+ float origin[3], distFromCenter[3];
+ GetEntityPositions(entity, origin, endOrigin[course], endAngles[course], distFromCenter);
+
+ // If it is a button or the center of the center of the zone is invalid
+ if (type == CourseTimerType_Button || !IsSpawnValid(endOrigin[course]))
+ {
+ // Attempt with various positions around the entity, pick the first valid one.
+ if (!FindValidPositionAroundTimerEntity(entity, endOrigin[course], endAngles[course], type == CourseTimerType_Button))
+ {
+ endOrigin[course][2] -= 64.0; // Move the origin down so the eye position is directly on top of the button/zone.
+ return;
+ }
+ }
+
+ // Only update the CourseTimerType if a valid position is found.
+ endType[course] = type;
+}
+
+static int GetEndButtonBonusNumber(int entity)
+{
+ return GOKZ_MatchIntFromEntityName(entity, RE_BonusEndButton, 1);
+}
+
+static int GetEndZoneBonusNumber(int entity)
+{
+ return GOKZ_MatchIntFromEntityName(entity, RE_BonusEndZone, 1);
+}
diff --git a/sourcemod/scripting/gokz-core/map/mapfile.sp b/sourcemod/scripting/gokz-core/map/mapfile.sp
new file mode 100644
index 0000000..db60e7e
--- /dev/null
+++ b/sourcemod/scripting/gokz-core/map/mapfile.sp
@@ -0,0 +1,502 @@
+/*
+ Mapping API
+
+ Reads data from the current map file.
+*/
+
+static Regex RE_BonusStartButton;
+static Regex RE_BonusEndButton;
+
+// NOTE: 4 megabyte array for entity lump reading.
+static char gEntityLump[4194304];
+
+// =====[ PUBLIC ]=====
+
+void EntlumpParse(StringMap antiBhopTriggers, StringMap teleportTriggers, StringMap timerButtonTriggers, int &mappingApiVersion)
+{
+ char mapPath[512];
+ GetCurrentMap(mapPath, sizeof(mapPath));
+ Format(mapPath, sizeof(mapPath), "maps/%s.bsp", mapPath);
+
+ // https://developer.valvesoftware.com/wiki/Source_BSP_File_Format
+
+ File file = OpenFile(mapPath, "rb");
+ if (file != INVALID_HANDLE)
+ {
+ int identifier;
+ file.ReadInt32(identifier);
+
+ if (identifier == GOKZ_BSP_HEADER_IDENTIFIER)
+ {
+ // skip version number
+ file.Seek(4, SEEK_CUR);
+
+ // the entity lump info is the first lump in the array, so we don't need to seek any further.
+ int offset;
+ int length;
+ file.ReadInt32(offset);
+ file.ReadInt32(length);
+
+ // jump to the start of the entity lump
+ file.Seek(offset, SEEK_SET);
+
+ int charactersRead = file.ReadString(gEntityLump, sizeof(gEntityLump), length);
+ delete file;
+ if (charactersRead >= sizeof(gEntityLump) - 1)
+ {
+ PushMappingApiError("ERROR: Entity lump: The map's entity lump is too big! Reduce the amount of entities in your map.");
+ return;
+ }
+ gEntityLump[length] = '\0';
+
+ int index = 0;
+
+ StringMap entity = new StringMap();
+ bool gotWorldSpawn = false;
+ while (EntlumpParseEntity(entity, gEntityLump, index))
+ {
+ char classname[128];
+ char targetName[GOKZ_ENTLUMP_MAX_VALUE];
+ entity.GetString("classname", classname, sizeof(classname));
+
+ if (!gotWorldSpawn && StrEqual("worldspawn", classname, false))
+ {
+ gotWorldSpawn = true;
+ char versionString[32];
+ if (entity.GetString("climb_mapping_api_version", versionString, sizeof(versionString)))
+ {
+ if (StringToIntEx(versionString, mappingApiVersion) == 0)
+ {
+ PushMappingApiError("ERROR: Entity lump: Couldn't parse Mapping API version from map properties: \"%s\".", versionString);
+ mappingApiVersion = GOKZ_MAPPING_API_VERSION_NONE;
+ }
+ }
+ else
+ {
+ // map doesn't have a mapping api version.
+ mappingApiVersion = GOKZ_MAPPING_API_VERSION_NONE;
+ }
+ }
+ else if (StrEqual("trigger_multiple", classname, false))
+ {
+ TriggerType triggerType;
+ if (!gotWorldSpawn || mappingApiVersion != GOKZ_MAPPING_API_VERSION_NONE)
+ {
+ if (entity.GetString("targetname", targetName, sizeof(targetName)))
+ {
+ // get trigger properties if applicable
+ triggerType = GetTriggerType(targetName);
+ if (triggerType == TriggerType_Antibhop)
+ {
+ AntiBhopTrigger trigger;
+ if (GetAntiBhopTriggerEntityProperties(trigger, entity))
+ {
+ char key[32];
+ IntToString(trigger.hammerID, key, sizeof(key));
+ antiBhopTriggers.SetArray(key, trigger, sizeof(trigger));
+ }
+ }
+ else if (triggerType == TriggerType_Teleport)
+ {
+ TeleportTrigger trigger;
+ if (GetTeleportTriggerEntityProperties(trigger, entity))
+ {
+ char key[32];
+ IntToString(trigger.hammerID, key, sizeof(key));
+ teleportTriggers.SetArray(key, trigger, sizeof(trigger));
+ }
+ }
+ }
+ }
+
+ // Tracking legacy timer triggers that press the timer buttons upon triggered.
+ if (triggerType == TriggerType_Invalid)
+ {
+ char touchOutput[128];
+ ArrayList value;
+
+ if (entity.GetString("OnStartTouch", touchOutput, sizeof(touchOutput)))
+ {
+ TimerButtonTriggerCheck(touchOutput, sizeof(touchOutput), entity, timerButtonTriggers);
+ }
+ else if (entity.GetValue("OnStartTouch", value)) // If there are multiple outputs, we have to check for all of them.
+ {
+ for (int i = 0; i < value.Length; i++)
+ {
+ value.GetString(i, touchOutput, sizeof(touchOutput));
+ TimerButtonTriggerCheck(touchOutput, sizeof(touchOutput), entity, timerButtonTriggers);
+ }
+ }
+ }
+ }
+ else if (StrEqual("func_button", classname, false))
+ {
+ char pressOutput[128];
+ ArrayList value;
+
+ if (entity.GetString("OnPressed", pressOutput, sizeof(pressOutput)))
+ {
+ TimerButtonTriggerCheck(pressOutput, sizeof(pressOutput), entity, timerButtonTriggers);
+ }
+ else if (entity.GetValue("OnPressed", value)) // If there are multiple outputs, we have to check for all of them.
+ {
+ for (int i = 0; i < value.Length; i++)
+ {
+ value.GetString(i, pressOutput, sizeof(pressOutput));
+ TimerButtonTriggerCheck(pressOutput, sizeof(pressOutput), entity, timerButtonTriggers);
+ }
+ }
+ }
+ // clear for next loop
+ entity.Clear();
+ }
+ delete entity;
+ }
+ delete file;
+ }
+ else
+ {
+ // TODO: do something more elegant
+ SetFailState("Catastrophic extreme hyperfailure! Mapping API Couldn't open the map file for reading! %s. The map file might be gone or another program is using it.", mapPath);
+ }
+}
+
+
+// =====[ EVENTS ]=====
+
+void OnPluginStart_MapFile()
+{
+ char buffer[64];
+ char press[8];
+ FormatEx(press, sizeof(press), "%s%s", CHAR_ESCAPE, "Press");
+
+ buffer = GOKZ_BONUS_START_BUTTON_NAME_REGEX;
+ ReplaceStringEx(buffer, sizeof(buffer), "$", "");
+ StrCat(buffer, sizeof(buffer), press);
+ RE_BonusStartButton = CompileRegex(buffer);
+
+ buffer = GOKZ_BONUS_END_BUTTON_NAME_REGEX;
+ ReplaceStringEx(buffer, sizeof(buffer), "$", "");
+ StrCat(buffer, sizeof(buffer), press);
+ RE_BonusEndButton = CompileRegex(buffer);
+}
+
+
+// =====[ PRIVATE ]=====
+
+static void EntlumpSkipAllWhiteSpace(char[] entityLump, int &index)
+{
+ while (IsCharSpace(entityLump[index]) && entityLump[index] != '\0')
+ {
+ index++;
+ }
+}
+
+static int EntlumpGetString(char[] result, int maxLength, int copyCount, char[] entityLump, int entlumpIndex)
+{
+ int finalLength;
+ for (int i = 0; i < maxLength - 1 && i < copyCount; i++)
+ {
+ if (entityLump[entlumpIndex + i] == '\0')
+ {
+ break;
+ }
+ result[i] = entityLump[entlumpIndex + i];
+ finalLength++;
+ }
+
+ result[finalLength] = '\0';
+ return finalLength;
+}
+
+static EntlumpToken EntlumpGetToken(char[] entityLump, int &entlumpIndex)
+{
+ EntlumpToken result;
+
+ EntlumpSkipAllWhiteSpace(entityLump, entlumpIndex);
+
+ switch (entityLump[entlumpIndex])
+ {
+ case '{':
+ {
+ result.type = EntlumpTokenType_OpenBrace;
+ EntlumpGetString(result.string, sizeof(result.string), 1, entityLump, entlumpIndex);
+ entlumpIndex++;
+ }
+ case '}':
+ {
+ result.type = EntlumpTokenType_CloseBrace;
+ EntlumpGetString(result.string, sizeof(result.string), 1, entityLump, entlumpIndex);
+ entlumpIndex++;
+ }
+ case '\0':
+ {
+ result.type = EntlumpTokenType_EndOfStream;
+ EntlumpGetString(result.string, sizeof(result.string), 1, entityLump, entlumpIndex);
+ entlumpIndex++;
+ }
+ case '\"':
+ {
+ result.type = EntlumpTokenType_Identifier;
+ int identifierLen;
+ entlumpIndex++;
+ for (int i = 0; i < sizeof(result.string) - 1; i++)
+ {
+ // NOTE: Unterminated strings can probably never happen, since the map has to be
+ // loaded by the game first and the engine will fail the load before we get to it.
+ if (entityLump[entlumpIndex + i] == '\0')
+ {
+ result.type = EntlumpTokenType_Unknown;
+ break;
+ }
+ if (entityLump[entlumpIndex + i] == '\"')
+ {
+ break;
+ }
+ result.string[i] = entityLump[entlumpIndex + i];
+ identifierLen++;
+ }
+
+ entlumpIndex += identifierLen + 1; // +1 to skip over last quotation mark
+ result.string[identifierLen] = '\0';
+ }
+ default:
+ {
+ result.type = EntlumpTokenType_Unknown;
+ result.string[0] = entityLump[entlumpIndex];
+ result.string[1] = '\0';
+ }
+ }
+
+ return result;
+}
+
+static bool EntlumpParseEntity(StringMap result, char[] entityLump, int &entlumpIndex)
+{
+ EntlumpToken token;
+ token = EntlumpGetToken(entityLump, entlumpIndex);
+ if (token.type == EntlumpTokenType_EndOfStream)
+ {
+ return false;
+ }
+
+ // NOTE: The following errors will very very likely never happen, since the entity lump has to be
+ // loaded by the game first and the engine will fail the load before we get to it.
+ // But if there's an obscure bug in this code, then we'll know!!!
+ for (;;)
+ {
+ token = EntlumpGetToken(entityLump, entlumpIndex);
+ switch (token.type)
+ {
+ case EntlumpTokenType_OpenBrace:
+ {
+ continue;
+ }
+ case EntlumpTokenType_Identifier:
+ {
+ EntlumpToken valueToken;
+ valueToken = EntlumpGetToken(entityLump, entlumpIndex);
+ if (valueToken.type == EntlumpTokenType_Identifier)
+ {
+ char tempString[GOKZ_ENTLUMP_MAX_VALUE];
+ ArrayList values;
+ if (result.GetString(token.string, tempString, sizeof(tempString)))
+ {
+ result.Remove(token.string);
+ values = new ArrayList(ByteCountToCells(GOKZ_ENTLUMP_MAX_VALUE));
+ values.PushString(tempString);
+ values.PushString(valueToken.string);
+ result.SetValue(token.string, values);
+ }
+ else if (result.GetValue(token.string, values))
+ {
+ values.PushString(valueToken.string);
+ }
+ else
+ {
+ result.SetString(token.string, valueToken.string);
+ }
+ }
+ else
+ {
+ PushMappingApiError("ERROR: Entity lump: Unexpected token \"%s\".", valueToken.string);
+ return false;
+ }
+ }
+ case EntlumpTokenType_CloseBrace:
+ {
+ break;
+ }
+ case EntlumpTokenType_EndOfStream:
+ {
+ PushMappingApiError("ERROR: Entity lump: Unexpected end of entity lump! Entity lump parsing failed.");
+ return false;
+ }
+ default:
+ {
+ PushMappingApiError("ERROR: Entity lump: Invalid token \"%s\". Entity lump parsing failed.", token.string);
+ return false;
+ }
+ }
+ }
+
+ return true;
+}
+
+static bool GetHammerIDFromEntityStringMap(int &result, StringMap entity)
+{
+ char hammerID[32];
+ if (!entity.GetString("hammerid", hammerID, sizeof(hammerID))
+ || StringToIntEx(hammerID, result) == 0)
+ {
+ // if we don't have the hammer id, then we can't match the entity to an existing one!
+ char origin[64];
+ entity.GetString("origin", origin, sizeof(origin));
+ PushMappingApiError("ERROR: Failed to parse \"hammerid\" keyvalue on trigger! \"%i\" origin: %s.", result, origin);
+ return false;
+ }
+ return true;
+}
+
+static bool GetAntiBhopTriggerEntityProperties(AntiBhopTrigger result, StringMap entity)
+{
+ if (!GetHammerIDFromEntityStringMap(result.hammerID, entity))
+ {
+ return false;
+ }
+
+ char time[32];
+ if (!entity.GetString("climb_anti_bhop_time", time, sizeof(time))
+ || StringToFloatEx(time, result.time) == 0)
+ {
+ result.time = GOKZ_ANTI_BHOP_TRIGGER_DEFAULT_DELAY;
+ }
+
+ return true;
+}
+
+static bool GetTeleportTriggerEntityProperties(TeleportTrigger result, StringMap entity)
+{
+ if (!GetHammerIDFromEntityStringMap(result.hammerID, entity))
+ {
+ return false;
+ }
+
+ char buffer[64];
+ if (!entity.GetString("climb_teleport_type", buffer, sizeof(buffer))
+ || StringToIntEx(buffer, view_as<int>(result.type)) == 0)
+ {
+ result.type = GOKZ_TELEPORT_TRIGGER_DEFAULT_TYPE;
+ }
+
+ if (!entity.GetString("climb_teleport_destination", result.tpDestination, sizeof(result.tpDestination)))
+ {
+ // We don't want triggers without destinations dangling about, so we need to tell everyone about it!!!
+ PushMappingApiError("ERROR: Could not find \"climb_teleport_destination\" keyvalue on a climb_teleport trigger! hammer id \"%i\".",
+ result.hammerID);
+ return false;
+ }
+
+ if (!entity.GetString("climb_teleport_delay", buffer, sizeof(buffer))
+ || StringToFloatEx(buffer, result.delay) == 0)
+ {
+ result.delay = GOKZ_TELEPORT_TRIGGER_DEFAULT_DELAY;
+ }
+
+ if (!entity.GetString("climb_teleport_use_dest_angles", buffer, sizeof(buffer))
+ || StringToIntEx(buffer, result.useDestAngles) == 0)
+ {
+ result.useDestAngles = GOKZ_TELEPORT_TRIGGER_DEFAULT_USE_DEST_ANGLES;
+ }
+
+ if (!entity.GetString("climb_teleport_reset_speed", buffer, sizeof(buffer))
+ || StringToIntEx(buffer, result.resetSpeed) == 0)
+ {
+ result.resetSpeed = GOKZ_TELEPORT_TRIGGER_DEFAULT_RESET_SPEED;
+ }
+
+ if (!entity.GetString("climb_teleport_reorient_player", buffer, sizeof(buffer))
+ || StringToIntEx(buffer, result.reorientPlayer) == 0)
+ {
+ result.reorientPlayer = GOKZ_TELEPORT_TRIGGER_DEFAULT_REORIENT_PLAYER;
+ }
+
+ if (!entity.GetString("climb_teleport_relative", buffer, sizeof(buffer))
+ || StringToIntEx(buffer, result.relativeDestination) == 0)
+ {
+ result.relativeDestination = GOKZ_TELEPORT_TRIGGER_DEFAULT_RELATIVE_DESTINATION;
+ }
+
+ // NOTE: Clamping
+ if (IsBhopTrigger(result.type))
+ {
+ result.delay = FloatMax(result.delay, GOKZ_TELEPORT_TRIGGER_BHOP_MIN_DELAY);
+ }
+ else
+ {
+ result.delay = FloatMax(result.delay, 0.0);
+ }
+
+ return true;
+}
+
+static void TimerButtonTriggerCheck(char[] touchOutput, int size, StringMap entity, StringMap timerButtonTriggers)
+{
+ int course = 0;
+ char startOutput[128];
+ char endOutput[128];
+ FormatEx(startOutput, sizeof(startOutput), "%s%s%s", GOKZ_START_BUTTON_NAME, CHAR_ESCAPE, "Press");
+ FormatEx(endOutput, sizeof(endOutput), "%s%s%s", GOKZ_END_BUTTON_NAME, CHAR_ESCAPE, "Press");
+ if (StrContains(touchOutput, startOutput, false) != -1)
+ {
+ TimerButtonTrigger trigger;
+ if (GetHammerIDFromEntityStringMap(trigger.hammerID, entity))
+ {
+ trigger.course = 0;
+ trigger.isStartTimer = true;
+ }
+ char key[32];
+ IntToString(trigger.hammerID, key, sizeof(key));
+ timerButtonTriggers.SetArray(key, trigger, sizeof(trigger));
+ }
+ else if (StrContains(touchOutput, endOutput, false) != -1)
+ {
+ TimerButtonTrigger trigger;
+ if (GetHammerIDFromEntityStringMap(trigger.hammerID, entity))
+ {
+ trigger.course = 0;
+ trigger.isStartTimer = false;
+ }
+ char key[32];
+ IntToString(trigger.hammerID, key, sizeof(key));
+ timerButtonTriggers.SetArray(key, trigger, sizeof(trigger));
+ }
+ else if (RE_BonusStartButton.Match(touchOutput) > 0)
+ {
+ RE_BonusStartButton.GetSubString(1, touchOutput, sizeof(size));
+ course = StringToInt(touchOutput);
+ TimerButtonTrigger trigger;
+ if (GetHammerIDFromEntityStringMap(trigger.hammerID, entity))
+ {
+ trigger.course = course;
+ trigger.isStartTimer = true;
+ }
+ char key[32];
+ IntToString(trigger.hammerID, key, sizeof(key));
+ timerButtonTriggers.SetArray(key, trigger, sizeof(trigger));
+ }
+ else if (RE_BonusEndButton.Match(touchOutput) > 0)
+ {
+ RE_BonusEndButton.GetSubString(1, touchOutput, sizeof(size));
+ course = StringToInt(touchOutput);
+ TimerButtonTrigger trigger;
+ if (GetHammerIDFromEntityStringMap(trigger.hammerID, entity))
+ {
+ trigger.course = course;
+ trigger.isStartTimer = false;
+ }
+ char key[32];
+ IntToString(trigger.hammerID, key, sizeof(key));
+ timerButtonTriggers.SetArray(key, trigger, sizeof(trigger));
+ }
+} \ No newline at end of file
diff --git a/sourcemod/scripting/gokz-core/map/prefix.sp b/sourcemod/scripting/gokz-core/map/prefix.sp
new file mode 100644
index 0000000..3ecbf89
--- /dev/null
+++ b/sourcemod/scripting/gokz-core/map/prefix.sp
@@ -0,0 +1,48 @@
+/*
+ Mapping API - Prefix
+
+ Detects the map's prefix.
+*/
+
+
+
+static int currentMapPrefix;
+
+
+
+// =====[ PUBLIC ]=====
+
+int GetCurrentMapPrefix()
+{
+ return currentMapPrefix;
+}
+
+
+
+// =====[ LISTENERS ]=====
+
+void OnMapStart_Prefix()
+{
+ char map[PLATFORM_MAX_PATH], mapPrefix[PLATFORM_MAX_PATH];
+ GetCurrentMapDisplayName(map, sizeof(map));
+
+ // Get all characters before the first '_' character
+ for (int i = 0; i < sizeof(mapPrefix); i++)
+ {
+ if (map[i] == '\0' || map[i] == '_')
+ {
+ break;
+ }
+
+ mapPrefix[i] = map[i];
+ }
+
+ if (StrEqual(mapPrefix[0], "kzpro", false))
+ {
+ currentMapPrefix = MapPrefix_KZPro;
+ }
+ else
+ {
+ currentMapPrefix = MapPrefix_Other;
+ }
+} \ No newline at end of file
diff --git a/sourcemod/scripting/gokz-core/map/starts.sp b/sourcemod/scripting/gokz-core/map/starts.sp
new file mode 100644
index 0000000..94d5b33
--- /dev/null
+++ b/sourcemod/scripting/gokz-core/map/starts.sp
@@ -0,0 +1,219 @@
+/*
+ Hooks between start destinations and GOKZ.
+*/
+
+
+
+static Regex RE_BonusStart;
+static bool startExists[GOKZ_MAX_COURSES];
+static float startOrigin[GOKZ_MAX_COURSES][3];
+static float startAngles[GOKZ_MAX_COURSES][3];
+
+// Used for SearchStart
+static Regex RE_BonusStartButton;
+static Regex RE_BonusStartZone;
+static CourseTimerType startType[GOKZ_MAX_COURSES];
+static float searchStartOrigin[GOKZ_MAX_COURSES][3];
+static float searchStartAngles[GOKZ_MAX_COURSES][3];
+
+// =====[ EVENTS ]=====
+
+void OnPluginStart_MapStarts()
+{
+ RE_BonusStart = CompileRegex(GOKZ_BONUS_START_NAME_REGEX);
+ RE_BonusStartButton = CompileRegex(GOKZ_BONUS_START_BUTTON_NAME_REGEX);
+ RE_BonusStartZone = CompileRegex(GOKZ_BONUS_START_ZONE_NAME_REGEX);
+}
+
+void OnEntitySpawned_MapStarts(int entity)
+{
+ char buffer[32];
+
+ GetEntityClassname(entity, buffer, sizeof(buffer));
+ if (!StrEqual("info_teleport_destination", buffer, false))
+ {
+ return;
+ }
+
+ if (GetEntityName(entity, buffer, sizeof(buffer)) == 0)
+ {
+ return;
+ }
+
+ if (StrEqual(GOKZ_START_NAME, buffer, false))
+ {
+ StoreStart(0, entity);
+ }
+ else
+ {
+ int course = GetStartBonusNumber(entity);
+ if (GOKZ_IsValidCourse(course, true))
+ {
+ StoreStart(course, entity);
+ }
+ }
+}
+
+void OnEntitySpawnedPost_MapStarts(int entity)
+{
+ char buffer[32];
+ GetEntityClassname(entity, buffer, sizeof(buffer));
+
+ if (StrEqual("trigger_multiple", buffer, false))
+ {
+ bool isStartZone;
+ if (GetEntityName(entity, buffer, sizeof(buffer)) != 0)
+ {
+ if (StrEqual(GOKZ_START_ZONE_NAME, buffer, false))
+ {
+ isStartZone = true;
+ StoreSearchStart(0, entity, CourseTimerType_ZoneNew);
+ }
+ else if (GetStartZoneBonusNumber(entity) != -1)
+ {
+ int course = GetStartZoneBonusNumber(entity);
+ if (GOKZ_IsValidCourse(course, true))
+ {
+ isStartZone = true;
+ StoreSearchStart(course, entity, CourseTimerType_ZoneNew);
+ }
+ }
+ }
+ if (!isStartZone)
+ {
+ TimerButtonTrigger trigger;
+ if (IsTimerButtonTrigger(entity, trigger) && trigger.isStartTimer)
+ {
+ StoreSearchStart(trigger.course, entity, CourseTimerType_ZoneLegacy);
+ }
+ }
+
+ }
+ else if (StrEqual("func_button", buffer, false))
+ {
+ bool isStartButton;
+ if (GetEntityName(entity, buffer, sizeof(buffer)) != 0)
+ {
+ if (StrEqual(GOKZ_START_BUTTON_NAME, buffer, false))
+ {
+ isStartButton = true;
+ StoreSearchStart(0, entity, CourseTimerType_Button);
+ }
+ else
+ {
+ int course = GetStartButtonBonusNumber(entity);
+ if (GOKZ_IsValidCourse(course, true))
+ {
+ isStartButton = true;
+ StoreSearchStart(course, entity, CourseTimerType_Button);
+ }
+ }
+ }
+ if (!isStartButton)
+ {
+ TimerButtonTrigger trigger;
+ if (IsTimerButtonTrigger(entity, trigger) && trigger.isStartTimer)
+ {
+ StoreSearchStart(trigger.course, entity, CourseTimerType_Button);
+ }
+ }
+ }
+}
+
+void OnMapStart_MapStarts()
+{
+ for (int course = 0; course < GOKZ_MAX_COURSES; course++)
+ {
+ startExists[course] = false;
+ startType[course] = CourseTimerType_None;
+ }
+}
+
+bool GetMapStartPosition(int course, float origin[3], float angles[3])
+{
+ if (!startExists[course])
+ {
+ return false;
+ }
+
+ origin = startOrigin[course];
+ angles = startAngles[course];
+
+ return true;
+}
+
+bool GetSearchStartPosition(int course, float origin[3], float angles[3])
+{
+ if (startType[course] == CourseTimerType_None)
+ {
+ return false;
+ }
+
+ origin = searchStartOrigin[course];
+ angles = searchStartAngles[course];
+
+ return true;
+}
+
+// =====[ PRIVATE ]=====
+
+static void StoreStart(int course, int entity)
+{
+ float origin[3], angles[3];
+ GetEntPropVector(entity, Prop_Send, "m_vecOrigin", origin);
+ GetEntPropVector(entity, Prop_Data, "m_angRotation", angles);
+ angles[2] = 0.0; // Roll should always be 0.0
+
+ startExists[course] = true;
+ startOrigin[course] = origin;
+ startAngles[course] = angles;
+}
+
+static void StoreSearchStart(int course, int entity, CourseTimerType type)
+{
+ // If StoreSearchStart is called, then there is at least an end position (even though it might not be a valid one)
+ if (startType[course] < CourseTimerType_Default)
+ {
+ startType[course] = CourseTimerType_Default;
+ }
+
+ // Real zone is always better than "fake" zones which are better than buttons
+ // as the buttons found in a map with fake zones aren't meant to be visible.
+ if (startType[course] >= type)
+ {
+ return;
+ }
+
+ float origin[3], distFromCenter[3];
+ GetEntityPositions(entity, origin, searchStartOrigin[course], searchStartAngles[course], distFromCenter);
+
+ // If it is a button or the center of the center of the zone is invalid
+ if (type == CourseTimerType_Button || !IsSpawnValid(searchStartOrigin[course]))
+ {
+ // Attempt with various positions around the entity, pick the first valid one.
+ if (!FindValidPositionAroundTimerEntity(entity, searchStartOrigin[course], searchStartAngles[course], type == CourseTimerType_Button))
+ {
+ searchStartOrigin[course][2] -= 64.0; // Move the origin down so the eye position is directly on top of the button/zone.
+ return;
+ }
+ }
+
+ // Only update the CourseTimerType if a valid position is found.
+ startType[course] = type;
+}
+
+
+static int GetStartBonusNumber(int entity)
+{
+ return GOKZ_MatchIntFromEntityName(entity, RE_BonusStart, 1);
+}
+
+static int GetStartButtonBonusNumber(int entity)
+{
+ return GOKZ_MatchIntFromEntityName(entity, RE_BonusStartButton, 1);
+}
+
+static int GetStartZoneBonusNumber(int entity)
+{
+ return GOKZ_MatchIntFromEntityName(entity, RE_BonusStartZone, 1);
+} \ No newline at end of file
diff --git a/sourcemod/scripting/gokz-core/map/triggers.sp b/sourcemod/scripting/gokz-core/map/triggers.sp
new file mode 100644
index 0000000..2444493
--- /dev/null
+++ b/sourcemod/scripting/gokz-core/map/triggers.sp
@@ -0,0 +1,855 @@
+/*
+ Mapping API - Triggers
+
+ Implements trigger related features.
+*/
+
+
+
+static float lastTrigMultiTouchTime[MAXPLAYERS + 1];
+static float lastTrigTeleTouchTime[MAXPLAYERS + 1];
+static float lastTouchGroundOrLadderTime[MAXPLAYERS + 1];
+static int lastTouchSingleBhopEntRef[MAXPLAYERS + 1];
+static ArrayList lastTouchSequentialBhopEntRefs[MAXPLAYERS + 1];
+static int triggerTouchCount[MAXPLAYERS + 1];
+static int antiCpTriggerTouchCount[MAXPLAYERS + 1];
+static int antiPauseTriggerTouchCount[MAXPLAYERS + 1];
+static int antiJumpstatTriggerTouchCount[MAXPLAYERS + 1];
+static int mapMappingApiVersion = GOKZ_MAPPING_API_VERSION_NONE;
+static int bhopTouchCount[MAXPLAYERS + 1];
+static bool jumpedThisTick[MAXPLAYERS + 1];
+static float jumpOrigin[MAXPLAYERS + 1][3];
+static float jumpVelocity[MAXPLAYERS + 1][3];
+static ArrayList triggerTouchList[MAXPLAYERS + 1]; // arraylist of TouchedTrigger that the player is currently touching. this array won't ever get long (unless the mapper does something weird).
+static StringMap triggerTouchCounts[MAXPLAYERS + 1]; // stringmap of int touch counts with key being a string of the entity reference.
+static StringMap antiBhopTriggers; // stringmap of AntiBhopTrigger with key being a string of the m_iHammerID entprop.
+static StringMap teleportTriggers; // stringmap of TeleportTrigger with key being a string of the m_iHammerID entprop.
+static StringMap timerButtonTriggers; // stringmap of legacy timer zone triggers with key being a string of the m_iHammerID entprop.
+static ArrayList parseErrorStrings;
+
+
+
+// =====[ PUBLIC ]=====
+
+bool BhopTriggersJustTouched(int client)
+{
+ // NOTE: This is slightly incorrect since we touch triggers in the air, but
+ // it doesn't matter since we can't checkpoint in the air.
+ if (bhopTouchCount[client] > 0)
+ {
+ return true;
+ }
+ // GetEngineTime return changes between calls. We only call it once at the beginning.
+ float engineTime = GetEngineTime();
+ // If the player touches a teleport trigger, increase the delay required
+ if (engineTime - lastTouchGroundOrLadderTime[client] < GOKZ_MULT_NO_CHECKPOINT_TIME // Just touched ground or ladder
+ && engineTime - lastTrigMultiTouchTime[client] < GOKZ_MULT_NO_CHECKPOINT_TIME // Just touched trigger_multiple
+ || engineTime - lastTrigTeleTouchTime[client] < GOKZ_BHOP_NO_CHECKPOINT_TIME) // Just touched trigger_teleport
+ {
+ return true;
+ }
+
+ return Movement_GetMovetype(client) == MOVETYPE_LADDER
+ && triggerTouchCount[client] > 0
+ && engineTime - lastTrigTeleTouchTime[client] < GOKZ_LADDER_NO_CHECKPOINT_TIME;
+}
+
+bool AntiCpTriggerIsTouched(int client)
+{
+ return antiCpTriggerTouchCount[client] > 0;
+}
+
+bool AntiPauseTriggerIsTouched(int client)
+{
+ return antiPauseTriggerTouchCount[client] > 0;
+}
+
+void PushMappingApiError(char[] format, any ...)
+{
+ char error[GOKZ_MAX_MAPTRIGGERS_ERROR_LENGTH];
+ VFormat(error, sizeof(error), format, 2);
+ parseErrorStrings.PushString(error);
+}
+
+TriggerType GetTriggerType(char[] targetName)
+{
+ TriggerType result = TriggerType_Invalid;
+
+ if (StrEqual(targetName, GOKZ_ANTI_BHOP_TRIGGER_NAME))
+ {
+ result = TriggerType_Antibhop;
+ }
+ else if (StrEqual(targetName, GOKZ_TELEPORT_TRIGGER_NAME))
+ {
+ result = TriggerType_Teleport;
+ }
+
+ return result;
+}
+
+bool IsBhopTrigger(TeleportType type)
+{
+ return type == TeleportType_MultiBhop
+ || type == TeleportType_SingleBhop
+ || type == TeleportType_SequentialBhop;
+}
+
+bool IsTimerButtonTrigger(int entity, TimerButtonTrigger trigger)
+{
+ char hammerID[32];
+ bool gotHammerID = GetEntityHammerIDString(entity, hammerID, sizeof(hammerID));
+ if (gotHammerID && timerButtonTriggers.GetArray(hammerID, trigger, sizeof(trigger)))
+ {
+ return true;
+ }
+ return false;
+}
+
+// =====[ EVENTS ]=====
+
+void OnPluginStart_MapTriggers()
+{
+ parseErrorStrings = new ArrayList(ByteCountToCells(GOKZ_MAX_MAPTRIGGERS_ERROR_LENGTH));
+ antiBhopTriggers = new StringMap();
+ teleportTriggers = new StringMap();
+ timerButtonTriggers = new StringMap();
+}
+
+void OnMapStart_MapTriggers()
+{
+ parseErrorStrings.Clear();
+ antiBhopTriggers.Clear();
+ teleportTriggers.Clear();
+ timerButtonTriggers.Clear();
+ mapMappingApiVersion = GOKZ_MAPPING_API_VERSION_NONE;
+ EntlumpParse(antiBhopTriggers, teleportTriggers, timerButtonTriggers, mapMappingApiVersion);
+
+ if (mapMappingApiVersion > GOKZ_MAPPING_API_VERSION)
+ {
+ SetFailState("Map's mapping api version is too big! Maximum supported version is %i, but map has %i. If you're not on the latest GOKZ version, then update!",
+ GOKZ_MAPPING_API_VERSION, mapMappingApiVersion);
+ }
+}
+
+void OnClientPutInServer_MapTriggers(int client)
+{
+ triggerTouchCount[client] = 0;
+ antiCpTriggerTouchCount[client] = 0;
+ antiPauseTriggerTouchCount[client] = 0;
+ antiJumpstatTriggerTouchCount[client] = 0;
+
+ if (triggerTouchList[client] == null)
+ {
+ triggerTouchList[client] = new ArrayList(sizeof(TouchedTrigger));
+ }
+ else
+ {
+ triggerTouchList[client].Clear();
+ }
+
+ if (triggerTouchCounts[client] == null)
+ {
+ triggerTouchCounts[client] = new StringMap();
+ }
+ else
+ {
+ triggerTouchCounts[client].Clear();
+ }
+
+ bhopTouchCount[client] = 0;
+
+ if (lastTouchSequentialBhopEntRefs[client] == null)
+ {
+ lastTouchSequentialBhopEntRefs[client] = new ArrayList();
+ }
+ else
+ {
+ lastTouchSequentialBhopEntRefs[client].Clear();
+ }
+}
+
+void OnPlayerRunCmd_MapTriggers(int client, int &buttons)
+{
+ int flags = GetEntityFlags(client);
+ MoveType moveType = GetEntityMoveType(client);
+
+ // if the player isn't touching any bhop triggers on ground/a ladder, then
+ // reset the singlebhop and sequential bhop state.
+ if ((flags & FL_ONGROUND || moveType == MOVETYPE_LADDER)
+ && bhopTouchCount[client] == 0)
+ {
+ ResetBhopState(client);
+ }
+
+ if (antiJumpstatTriggerTouchCount[client] > 0)
+ {
+ if (GetFeatureStatus(FeatureType_Native, "GOKZ_JS_InvalidateJump") == FeatureStatus_Available)
+ {
+ GOKZ_JS_InvalidateJump(client);
+ }
+ }
+
+ // Check if we're touching any triggers and act accordingly.
+ // NOTE: Read through the touch list in reverse order, so some
+ // trigger behaviours will be better. Trust me!
+ int triggerTouchListLength = triggerTouchList[client].Length;
+ for (int i = triggerTouchListLength - 1; i >= 0; i--)
+ {
+ TouchedTrigger touched;
+ triggerTouchList[client].GetArray(i, touched);
+
+ if (touched.triggerType == TriggerType_Antibhop)
+ {
+ TouchAntibhopTrigger(client, touched, buttons, flags);
+ }
+ else if (touched.triggerType == TriggerType_Teleport)
+ {
+ // Sometimes due to lag or whatever, the player can be
+ // teleported twice by the same trigger. This fixes that.
+ if (TouchTeleportTrigger(client, touched, flags))
+ {
+ RemoveTriggerFromTouchList(client, EntRefToEntIndex(touched.entRef));
+ i--;
+ triggerTouchListLength--;
+ }
+ }
+ }
+ jumpedThisTick[client] = false;
+}
+
+void OnPlayerSpawn_MapTriggers(int client)
+{
+ // Print trigger errors every time a player spawns so that
+ // mappers and testers can very easily spot mistakes in names
+ // and get them fixed asap.
+ if (parseErrorStrings.Length > 0)
+ {
+ char errStart[] = "ERROR: Errors detected when trying to load triggers!";
+ CPrintToChat(client, "{red}%s", errStart);
+ PrintToConsole(client, "\n%s", errStart);
+
+ int length = parseErrorStrings.Length;
+ for (int err = 0; err < length; err++)
+ {
+ char error[GOKZ_MAX_MAPTRIGGERS_ERROR_LENGTH];
+ parseErrorStrings.GetString(err, error, sizeof(error));
+ CPrintToChat(client, "{red}%s", error);
+ PrintToConsole(client, error);
+ }
+ CPrintToChat(client, "{red}If the errors get clipped off in the chat, then look in your developer console!\n");
+ }
+}
+
+public void OnPlayerJump_Triggers(int client)
+{
+ jumpedThisTick[client] = true;
+ GetClientAbsOrigin(client, jumpOrigin[client]);
+ Movement_GetVelocity(client, jumpVelocity[client]);
+}
+
+void OnEntitySpawned_MapTriggers(int entity)
+{
+ char classname[32];
+ GetEntityClassname(entity, classname, sizeof(classname));
+ char name[64];
+ GetEntityName(entity, name, sizeof(name));
+
+ bool triggerMultiple = StrEqual("trigger_multiple", classname);
+ if (triggerMultiple)
+ {
+ char hammerID[32];
+ bool gotHammerID = GetEntityHammerIDString(entity, hammerID, sizeof(hammerID));
+
+ if (StrEqual(GOKZ_TELEPORT_TRIGGER_NAME, name))
+ {
+ TeleportTrigger teleportTrigger;
+ if (gotHammerID && teleportTriggers.GetArray(hammerID, teleportTrigger, sizeof(teleportTrigger)))
+ {
+ HookSingleEntityOutput(entity, "OnStartTouch", OnTeleportTrigTouchStart_MapTriggers);
+ HookSingleEntityOutput(entity, "OnEndTouch", OnTeleportTrigTouchEnd_MapTriggers);
+ }
+ else
+ {
+ PushMappingApiError("ERROR: Couldn't match teleport trigger's Hammer ID %s with any Hammer ID from the map.", hammerID);
+ }
+ }
+ else if (StrEqual(GOKZ_ANTI_BHOP_TRIGGER_NAME, name))
+ {
+ AntiBhopTrigger antiBhopTrigger;
+ if (gotHammerID && antiBhopTriggers.GetArray(hammerID, antiBhopTrigger, sizeof(antiBhopTrigger)))
+ {
+ HookSingleEntityOutput(entity, "OnStartTouch", OnAntiBhopTrigTouchStart_MapTriggers);
+ HookSingleEntityOutput(entity, "OnEndTouch", OnAntiBhopTrigTouchEnd_MapTriggers);
+ }
+ else
+ {
+ PushMappingApiError("ERROR: Couldn't match antibhop trigger's Hammer ID %s with any Hammer ID from the map.", hammerID);
+ }
+ }
+ else if (StrEqual(GOKZ_BHOP_RESET_TRIGGER_NAME, name))
+ {
+ HookSingleEntityOutput(entity, "OnStartTouch", OnBhopResetTouchStart_MapTriggers);
+ }
+ else if (StrEqual(GOKZ_ANTI_CP_TRIGGER_NAME, name, false))
+ {
+ HookSingleEntityOutput(entity, "OnStartTouch", OnAntiCpTrigTouchStart_MapTriggers);
+ HookSingleEntityOutput(entity, "OnEndTouch", OnAntiCpTrigTouchEnd_MapTriggers);
+ }
+ else if (StrEqual(GOKZ_ANTI_PAUSE_TRIGGER_NAME, name, false))
+ {
+ HookSingleEntityOutput(entity, "OnStartTouch", OnAntiPauseTrigTouchStart_MapTriggers);
+ HookSingleEntityOutput(entity, "OnEndTouch", OnAntiPauseTrigTouchEnd_MapTriggers);
+ }
+ else if (StrEqual(GOKZ_ANTI_JUMPSTAT_TRIGGER_NAME, name, false))
+ {
+ HookSingleEntityOutput(entity, "OnStartTouch", OnAntiJumpstatTrigTouchStart_MapTriggers);
+ HookSingleEntityOutput(entity, "OnEndTouch", OnAntiJumpstatTrigTouchEnd_MapTriggers);
+ }
+ else
+ {
+ // NOTE: SDKHook touch hooks bypass trigger filters. We want that only with
+ // non mapping api triggers because it prevents checkpointing on bhop blocks.
+ SDKHook(entity, SDKHook_StartTouchPost, OnTrigMultTouchStart_MapTriggers);
+ SDKHook(entity, SDKHook_EndTouchPost, OnTrigMultTouchEnd_MapTriggers);
+ }
+ }
+ else if (StrEqual("trigger_teleport", classname))
+ {
+ SDKHook(entity, SDKHook_StartTouchPost, OnTrigTeleTouchStart_MapTriggers);
+ SDKHook(entity, SDKHook_EndTouchPost, OnTrigTeleTouchEnd_MapTriggers);
+ }
+}
+
+public void OnAntiBhopTrigTouchStart_MapTriggers(const char[] output, int entity, int other, float delay)
+{
+ if (!IsValidClient(other))
+ {
+ return;
+ }
+
+ int touchCount = IncrementTriggerTouchCount(other, entity);
+ if (touchCount <= 0)
+ {
+ // The trigger has fired a matching endtouch output before
+ // the starttouch output, so ignore it.
+ return;
+ }
+
+ if (jumpedThisTick[other])
+ {
+ TeleportEntity(other, jumpOrigin[other], NULL_VECTOR, jumpVelocity[other]);
+ }
+
+ AddTriggerToTouchList(other, entity, TriggerType_Antibhop);
+}
+
+public void OnAntiBhopTrigTouchEnd_MapTriggers(const char[] output, int entity, int other, float delay)
+{
+ if (!IsValidClient(other))
+ {
+ return;
+ }
+
+ DecrementTriggerTouchCount(other, entity);
+ RemoveTriggerFromTouchList(other, entity);
+}
+
+public void OnTeleportTrigTouchStart_MapTriggers(const char[] output, int entity, int other, float delay)
+{
+ if (!IsValidClient(other))
+ {
+ return;
+ }
+
+ int touchCount = IncrementTriggerTouchCount(other, entity);
+ if (touchCount <= 0)
+ {
+ // The trigger has fired a matching endtouch output before
+ // the starttouch output, so ignore it.
+ return;
+ }
+
+ char key[32];
+ GetEntityHammerIDString(entity, key, sizeof(key));
+ TeleportTrigger trigger;
+ if (teleportTriggers.GetArray(key, trigger, sizeof(trigger))
+ && IsBhopTrigger(trigger.type))
+ {
+ bhopTouchCount[other]++;
+ }
+
+ AddTriggerToTouchList(other, entity, TriggerType_Teleport);
+}
+
+public void OnTeleportTrigTouchEnd_MapTriggers(const char[] output, int entity, int other, float delay)
+{
+ if (!IsValidClient(other))
+ {
+ return;
+ }
+
+ DecrementTriggerTouchCount(other, entity);
+
+ char key[32];
+ GetEntityHammerIDString(entity, key, sizeof(key));
+ TeleportTrigger trigger;
+ if (teleportTriggers.GetArray(key, trigger, sizeof(trigger))
+ && IsBhopTrigger(trigger.type))
+ {
+ bhopTouchCount[other]--;
+ }
+
+ RemoveTriggerFromTouchList(other, entity);
+}
+
+public void OnBhopResetTouchStart_MapTriggers(const char[] output, int entity, int other, float delay)
+{
+ if (!IsValidClient(other))
+ {
+ return;
+ }
+
+ ResetBhopState(other);
+}
+
+public void OnAntiCpTrigTouchStart_MapTriggers(const char[] output, int entity, int other, float delay)
+{
+ if (!IsValidClient(other))
+ {
+ return;
+ }
+
+ antiCpTriggerTouchCount[other]++;
+}
+
+public void OnAntiCpTrigTouchEnd_MapTriggers(const char[] output, int entity, int other, float delay)
+{
+ if (!IsValidClient(other))
+ {
+ return;
+ }
+
+ antiCpTriggerTouchCount[other]--;
+}
+
+public void OnAntiPauseTrigTouchStart_MapTriggers(const char[] output, int entity, int other, float delay)
+{
+ if (!IsValidClient(other))
+ {
+ return;
+ }
+
+ antiPauseTriggerTouchCount[other]++;
+}
+
+public void OnAntiPauseTrigTouchEnd_MapTriggers(const char[] output, int entity, int other, float delay)
+{
+ if (!IsValidClient(other))
+ {
+ return;
+ }
+
+ antiPauseTriggerTouchCount[other]--;
+}
+
+public void OnAntiJumpstatTrigTouchStart_MapTriggers(const char[] output, int entity, int other, float delay)
+{
+ if (!IsValidClient(other))
+ {
+ return;
+ }
+
+ antiJumpstatTriggerTouchCount[other]++;
+}
+
+public void OnAntiJumpstatTrigTouchEnd_MapTriggers(const char[] output, int entity, int other, float delay)
+{
+ if (!IsValidClient(other))
+ {
+ return;
+ }
+
+ antiJumpstatTriggerTouchCount[other]--;
+}
+
+public void OnTrigMultTouchStart_MapTriggers(int entity, int other)
+{
+ if (!IsValidClient(other))
+ {
+ return;
+ }
+
+ lastTrigMultiTouchTime[other] = GetEngineTime();
+ triggerTouchCount[other]++;
+}
+
+public void OnTrigMultTouchEnd_MapTriggers(int entity, int other)
+{
+ if (!IsValidClient(other))
+ {
+ return;
+ }
+
+ triggerTouchCount[other]--;
+}
+
+public void OnTrigTeleTouchStart_MapTriggers(int entity, int other)
+{
+ if (!IsValidClient(other))
+ {
+ return;
+ }
+
+ lastTrigTeleTouchTime[other] = GetEngineTime();
+ triggerTouchCount[other]++;
+}
+
+public void OnTrigTeleTouchEnd_MapTriggers(int entity, int other)
+{
+ if (!IsValidClient(other))
+ {
+ return;
+ }
+
+ triggerTouchCount[other]--;
+}
+
+void OnStartTouchGround_MapTriggers(int client)
+{
+ lastTouchGroundOrLadderTime[client] = GetEngineTime();
+
+ for (int i = 0; i < triggerTouchList[client].Length; i++)
+ {
+ TouchedTrigger touched;
+ triggerTouchList[client].GetArray(i, touched);
+ // set the touched tick to the tick that the player touches the ground.
+ touched.groundTouchTick = gI_TickCount[client];
+ triggerTouchList[client].SetArray(i, touched);
+ }
+}
+
+void OnStopTouchGround_MapTriggers(int client)
+{
+ for (int i = 0; i < triggerTouchList[client].Length; i++)
+ {
+ TouchedTrigger touched;
+ triggerTouchList[client].GetArray(i, touched);
+
+ if (touched.triggerType == TriggerType_Teleport)
+ {
+ char key[32];
+ GetEntityHammerIDString(touched.entRef, key, sizeof(key));
+ TeleportTrigger trigger;
+ // set last touched triggers for single and sequential bhop.
+ if (teleportTriggers.GetArray(key, trigger, sizeof(trigger))
+ && IsBhopTrigger(trigger.type))
+ {
+ if (trigger.type == TeleportType_SequentialBhop)
+ {
+ lastTouchSequentialBhopEntRefs[client].Push(touched.entRef);
+ }
+ // NOTE: For singlebhops, we don't care which type of bhop we last touched, because
+ // otherwise jumping back and forth between a multibhop and a singlebhop wouldn't work.
+ if (i == 0 && IsBhopTrigger(trigger.type))
+ {
+ // We only want to set this once in this loop.
+ lastTouchSingleBhopEntRef[client] = touched.entRef;
+ }
+ }
+ }
+ }
+}
+
+void OnChangeMovetype_MapTriggers(int client, MoveType newMovetype)
+{
+ if (newMovetype == MOVETYPE_LADDER)
+ {
+ lastTouchGroundOrLadderTime[client] = GetEngineTime();
+ }
+}
+
+
+
+// =====[ PRIVATE ]=====
+
+static void AddTriggerToTouchList(int client, int trigger, TriggerType triggerType)
+{
+ int triggerEntRef = EntIndexToEntRef(trigger);
+
+ TouchedTrigger touched;
+ touched.triggerType = triggerType;
+ touched.entRef = triggerEntRef;
+ touched.startTouchTick = gI_TickCount[client];
+ touched.groundTouchTick = -1;
+ if (GetEntityFlags(client) & FL_ONGROUND)
+ {
+ touched.groundTouchTick = gI_TickCount[client];
+ }
+
+ triggerTouchList[client].PushArray(touched);
+}
+
+static void RemoveTriggerFromTouchList(int client, int trigger)
+{
+ int triggerEntRef = EntIndexToEntRef(trigger);
+ for (int i = 0; i < triggerTouchList[client].Length; i++)
+ {
+ TouchedTrigger touched;
+ triggerTouchList[client].GetArray(i, touched);
+ if (touched.entRef == triggerEntRef)
+ {
+ triggerTouchList[client].Erase(i);
+ break;
+ }
+ }
+}
+
+static int IncrementTriggerTouchCount(int client, int trigger)
+{
+ int entref = EntIndexToEntRef(trigger);
+ char szEntref[64];
+ FormatEx(szEntref, sizeof(szEntref), "%i", entref);
+
+ int value = 0;
+ triggerTouchCounts[client].GetValue(szEntref, value);
+
+ value += 1;
+ triggerTouchCounts[client].SetValue(szEntref, value);
+
+ return value;
+}
+
+static void DecrementTriggerTouchCount(int client, int trigger)
+{
+ int entref = EntIndexToEntRef(trigger);
+ char szEntref[64];
+ FormatEx(szEntref, sizeof(szEntref), "%i", entref);
+
+ int value = 0;
+ triggerTouchCounts[client].GetValue(szEntref, value);
+
+ value -= 1;
+ triggerTouchCounts[client].SetValue(szEntref, value);
+}
+
+static void TouchAntibhopTrigger(int client, TouchedTrigger touched, int &newButtons, int flags)
+{
+ if (!(flags & FL_ONGROUND))
+ {
+ // Disable jump when the player is in the air.
+ // This is a very simple way to fix jumpbugging antibhop triggers.
+ newButtons &= ~IN_JUMP;
+ return;
+ }
+
+ if (touched.groundTouchTick == -1)
+ {
+ // The player hasn't touched the ground inside this trigger yet.
+ return;
+ }
+
+ char key[32];
+ GetEntityHammerIDString(touched.entRef, key, sizeof(key));
+ AntiBhopTrigger trigger;
+ if (antiBhopTriggers.GetArray(key, trigger, sizeof(trigger)))
+ {
+ float touchTime = CalculateGroundTouchTime(client, touched);
+ if (trigger.time == 0.0 || touchTime <= trigger.time)
+ {
+ // disable jump
+ newButtons &= ~IN_JUMP;
+ }
+ }
+}
+
+static bool TouchTeleportTrigger(int client, TouchedTrigger touched, int flags)
+{
+ bool shouldTeleport = false;
+
+ char key[32];
+ GetEntityHammerIDString(touched.entRef, key, sizeof(key));
+ TeleportTrigger trigger;
+ if (!teleportTriggers.GetArray(key, trigger, sizeof(trigger)))
+ {
+ // Couldn't get the teleport trigger from the trigger array for some reason.
+ return shouldTeleport;
+ }
+
+ bool isBhopTrigger = IsBhopTrigger(trigger.type);
+ // NOTE: Player hasn't touched the ground inside this trigger yet.
+ if (touched.groundTouchTick == -1 && isBhopTrigger)
+ {
+ return shouldTeleport;
+ }
+
+ float destOrigin[3];
+ float destAngles[3];
+ bool gotDestOrigin;
+ bool gotDestAngles;
+ int destinationEnt = GetTeleportDestinationAndOrientation(trigger.tpDestination, destOrigin, destAngles, gotDestOrigin, gotDestAngles);
+
+ float triggerOrigin[3];
+ bool gotTriggerOrigin = GetEntityAbsOrigin(touched.entRef, triggerOrigin);
+
+ // NOTE: We only use the trigger's origin if we're using a relative destination, so if
+ // we're not using a relative destination and don't have it, then it's fine.
+ if (!IsValidEntity(destinationEnt) || !gotDestOrigin
+ || (!gotTriggerOrigin && trigger.relativeDestination))
+ {
+ PrintToConsole(client, "[KZ] Invalid teleport destination \"%s\" on trigger with hammerID %i.", trigger.tpDestination, trigger.hammerID);
+ return shouldTeleport;
+ }
+
+ // NOTE: Find out if we should actually teleport.
+ if (isBhopTrigger && (flags & FL_ONGROUND))
+ {
+ float touchTime = CalculateGroundTouchTime(client, touched);
+ if (touchTime > trigger.delay)
+ {
+ shouldTeleport = true;
+ }
+ else if (trigger.type == TeleportType_SingleBhop)
+ {
+ shouldTeleport = lastTouchSingleBhopEntRef[client] == touched.entRef;
+ }
+ else if (trigger.type == TeleportType_SequentialBhop)
+ {
+ int length = lastTouchSequentialBhopEntRefs[client].Length;
+ for (int j = 0; j < length; j++)
+ {
+ int entRef = lastTouchSequentialBhopEntRefs[client].Get(j);
+ if (entRef == touched.entRef)
+ {
+ shouldTeleport = true;
+ break;
+ }
+ }
+ }
+ }
+ else if (trigger.type == TeleportType_Normal)
+ {
+ float touchTime = CalculateStartTouchTime(client, touched);
+ shouldTeleport = touchTime > trigger.delay || (trigger.delay == 0.0);
+ }
+
+ if (!shouldTeleport)
+ {
+ return shouldTeleport;
+ }
+
+ bool shouldReorientPlayer = trigger.reorientPlayer
+ && gotDestAngles && (destAngles[1] != 0.0);
+
+ float zAxis[3];
+ zAxis = view_as<float>({0.0, 0.0, 1.0});
+
+ // NOTE: Work out finalOrigin.
+ float finalOrigin[3];
+ if (trigger.relativeDestination)
+ {
+ float playerOrigin[3];
+ Movement_GetOrigin(client, playerOrigin);
+
+ float playerOffsetFromTrigger[3];
+ SubtractVectors(playerOrigin, triggerOrigin, playerOffsetFromTrigger);
+
+ if (shouldReorientPlayer)
+ {
+ // NOTE: rotate player offset by the destination trigger's yaw.
+ RotateVectorAxis(playerOffsetFromTrigger, zAxis, DegToRad(destAngles[1]), playerOffsetFromTrigger);
+ }
+
+ AddVectors(destOrigin, playerOffsetFromTrigger, finalOrigin);
+ }
+ else
+ {
+ finalOrigin = destOrigin;
+ }
+
+ // NOTE: Work out finalPlayerAngles.
+ float finalPlayerAngles[3];
+ Movement_GetEyeAngles(client, finalPlayerAngles);
+ if (shouldReorientPlayer)
+ {
+ finalPlayerAngles[1] -= destAngles[1];
+
+ float velocity[3];
+ Movement_GetVelocity(client, velocity);
+
+ // NOTE: rotate velocity by the destination trigger's yaw.
+ RotateVectorAxis(velocity, zAxis, DegToRad(destAngles[1]), velocity);
+ Movement_SetVelocity(client, velocity);
+ }
+ else if (!trigger.reorientPlayer && trigger.useDestAngles)
+ {
+ finalPlayerAngles = destAngles;
+ }
+
+ if (shouldTeleport)
+ {
+ TeleportPlayer(client, finalOrigin, finalPlayerAngles, gotDestAngles && trigger.useDestAngles, trigger.resetSpeed);
+ }
+
+ return shouldTeleport;
+}
+
+static float CalculateGroundTouchTime(int client, TouchedTrigger touched)
+{
+ float result = float(gI_TickCount[client] - touched.groundTouchTick) * GetTickInterval();
+ return result;
+}
+
+static float CalculateStartTouchTime(int client, TouchedTrigger touched)
+{
+ float result = float(gI_TickCount[client] - touched.startTouchTick) * GetTickInterval();
+ return result;
+}
+
+static void ResetBhopState(int client)
+{
+ lastTouchSingleBhopEntRef[client] = INVALID_ENT_REFERENCE;
+ lastTouchSequentialBhopEntRefs[client].Clear();
+}
+
+static bool GetEntityHammerIDString(int entity, char[] buffer, int maxLength)
+{
+ if (!IsValidEntity(entity))
+ {
+ return false;
+ }
+
+ if (!HasEntProp(entity, Prop_Data, "m_iHammerID"))
+ {
+ return false;
+ }
+
+ int hammerID = GetEntProp(entity, Prop_Data, "m_iHammerID");
+ IntToString(hammerID, buffer, maxLength);
+
+ return true;
+}
+
+// NOTE: returns an entity reference (possibly invalid).
+static int GetTeleportDestinationAndOrientation(char[] targetName, float origin[3], float angles[3] = NULL_VECTOR, bool &gotOrigin = false, bool &gotAngles = false)
+{
+ // NOTE: We're not caching the teleport destination because it could change.
+ int destination = GOKZFindEntityByName(targetName, .ignorePlayers = true);
+ if (!IsValidEntity(destination))
+ {
+ return destination;
+ }
+
+ gotOrigin = GetEntityAbsOrigin(destination, origin);
+
+ if (HasEntProp(destination, Prop_Data, "m_angAbsRotation"))
+ {
+ GetEntPropVector(destination, Prop_Data, "m_angAbsRotation", angles);
+ gotAngles = true;
+ }
+ else
+ {
+ gotAngles = false;
+ }
+
+ return destination;
+} \ No newline at end of file
diff --git a/sourcemod/scripting/gokz-core/map/zones.sp b/sourcemod/scripting/gokz-core/map/zones.sp
new file mode 100644
index 0000000..684e42d
--- /dev/null
+++ b/sourcemod/scripting/gokz-core/map/zones.sp
@@ -0,0 +1,183 @@
+/*
+ Hooks between specifically named trigger_multiples and GOKZ.
+*/
+
+
+
+static Regex RE_BonusStartZone;
+static Regex RE_BonusEndZone;
+static bool touchedGroundSinceTouchingStartZone[MAXPLAYERS + 1];
+
+
+
+// =====[ EVENTS ]=====
+
+void OnPluginStart_MapZones()
+{
+ RE_BonusStartZone = CompileRegex(GOKZ_BONUS_START_ZONE_NAME_REGEX);
+ RE_BonusEndZone = CompileRegex(GOKZ_BONUS_END_ZONE_NAME_REGEX);
+}
+
+void OnStartTouchGround_MapZones(int client)
+{
+ touchedGroundSinceTouchingStartZone[client] = true;
+}
+
+void OnEntitySpawned_MapZones(int entity)
+{
+ char buffer[32];
+
+ GetEntityClassname(entity, buffer, sizeof(buffer));
+ if (!StrEqual("trigger_multiple", buffer, false))
+ {
+ return;
+ }
+
+ if (GetEntityName(entity, buffer, sizeof(buffer)) == 0)
+ {
+ return;
+ }
+
+ int course = 0;
+ if (StrEqual(GOKZ_START_ZONE_NAME, buffer, false))
+ {
+ HookSingleEntityOutput(entity, "OnStartTouch", OnStartZoneStartTouch);
+ HookSingleEntityOutput(entity, "OnEndTouch", OnStartZoneEndTouch);
+ RegisterCourseStart(course);
+ }
+ else if (StrEqual(GOKZ_END_ZONE_NAME, buffer, false))
+ {
+ HookSingleEntityOutput(entity, "OnStartTouch", OnEndZoneStartTouch);
+ RegisterCourseEnd(course);
+ }
+ else if ((course = GetStartZoneBonusNumber(entity)) != -1)
+ {
+ HookSingleEntityOutput(entity, "OnStartTouch", OnBonusStartZoneStartTouch);
+ HookSingleEntityOutput(entity, "OnEndTouch", OnBonusStartZoneEndTouch);
+ RegisterCourseStart(course);
+ }
+ else if ((course = GetEndZoneBonusNumber(entity)) != -1)
+ {
+ HookSingleEntityOutput(entity, "OnStartTouch", OnBonusEndZoneStartTouch);
+ RegisterCourseEnd(course);
+ }
+}
+
+public void OnStartZoneStartTouch(const char[] name, int caller, int activator, float delay)
+{
+ if (!IsValidEntity(caller) || !IsValidClient(activator))
+ {
+ return;
+ }
+
+ ProcessStartZoneStartTouch(activator, 0);
+}
+
+public void OnStartZoneEndTouch(const char[] name, int caller, int activator, float delay)
+{
+ if (!IsValidEntity(caller) || !IsValidClient(activator))
+ {
+ return;
+ }
+
+ ProcessStartZoneEndTouch(activator, 0);
+}
+
+public void OnEndZoneStartTouch(const char[] name, int caller, int activator, float delay)
+{
+ if (!IsValidEntity(caller) || !IsValidClient(activator))
+ {
+ return;
+ }
+
+ ProcessEndZoneStartTouch(activator, 0);
+}
+
+public void OnBonusStartZoneStartTouch(const char[] name, int caller, int activator, float delay)
+{
+ if (!IsValidEntity(caller) || !IsValidClient(activator))
+ {
+ return;
+ }
+
+ int course = GetStartZoneBonusNumber(caller);
+ if (!GOKZ_IsValidCourse(course, true))
+ {
+ return;
+ }
+
+ ProcessStartZoneStartTouch(activator, course);
+}
+
+public void OnBonusStartZoneEndTouch(const char[] name, int caller, int activator, float delay)
+{
+ if (!IsValidEntity(caller) || !IsValidClient(activator))
+ {
+ return;
+ }
+
+ int course = GetStartZoneBonusNumber(caller);
+ if (!GOKZ_IsValidCourse(course, true))
+ {
+ return;
+ }
+
+ ProcessStartZoneEndTouch(activator, course);
+}
+
+public void OnBonusEndZoneStartTouch(const char[] name, int caller, int activator, float delay)
+{
+ if (!IsValidEntity(caller) || !IsValidClient(activator))
+ {
+ return;
+ }
+
+ int course = GetEndZoneBonusNumber(caller);
+ if (!GOKZ_IsValidCourse(course, true))
+ {
+ return;
+ }
+
+ ProcessEndZoneStartTouch(activator, course);
+}
+
+
+
+// =====[ PRIVATE ]=====
+
+static void ProcessStartZoneStartTouch(int client, int course)
+{
+ touchedGroundSinceTouchingStartZone[client] = Movement_GetOnGround(client);
+
+ GOKZ_StopTimer(client, false);
+ SetCurrentCourse(client, course);
+
+ OnStartZoneStartTouch_Teleports(client, course);
+}
+
+static void ProcessStartZoneEndTouch(int client, int course)
+{
+ if (!touchedGroundSinceTouchingStartZone[client])
+ {
+ return;
+ }
+
+ GOKZ_StartTimer(client, course, true);
+ GOKZ_ResetVirtualButtonPosition(client, true);
+}
+
+static void ProcessEndZoneStartTouch(int client, int course)
+{
+ GOKZ_EndTimer(client, course);
+ GOKZ_ResetVirtualButtonPosition(client, false);
+}
+
+static int GetStartZoneBonusNumber(int entity)
+{
+ return GOKZ_MatchIntFromEntityName(entity, RE_BonusStartZone, 1);
+}
+
+static int GetEndZoneBonusNumber(int entity)
+{
+ return GOKZ_MatchIntFromEntityName(entity, RE_BonusEndZone, 1);
+} \ No newline at end of file