diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000..bd7d6ed --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,28 @@ +# This workflow will build a .NET project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net + +name: .NET + +on: + push: + branches: [ "main", "dev" ] + pull_request: + branches: [ "main", "dev" ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 7.0.x + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore + - name: Test + run: dotnet test --no-build --verbosity normal diff --git a/.gitignore b/.gitignore index 59bfa40..7812496 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ *.vs +*.vscode +*.idea src/bin/Debug/* src/obj/* src/SurfTimer.csproj \ No newline at end of file diff --git a/CS2SurfTimer.sln b/CS2SurfTimer.sln new file mode 100644 index 0000000..ab56b21 --- /dev/null +++ b/CS2SurfTimer.sln @@ -0,0 +1,30 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{A9962DB7-AE8A-4370-B381-19529A91B7EC}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SurfTimer", "src\SurfTimer.csproj", "{98841535-B479-49B7-8D35-03786D4C31B9}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {98841535-B479-49B7-8D35-03786D4C31B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {98841535-B479-49B7-8D35-03786D4C31B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {98841535-B479-49B7-8D35-03786D4C31B9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {98841535-B479-49B7-8D35-03786D4C31B9}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {98841535-B479-49B7-8D35-03786D4C31B9} = {A9962DB7-AE8A-4370-B381-19529A91B7EC} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {2AE70C13-97FA-4E60-93C0-94275277887E} + EndGlobalSection +EndGlobal diff --git a/LICENSE b/LICENSE index f288702..0ad25db 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,5 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies @@ -7,17 +7,15 @@ Preamble - The GNU General Public License is a free, copyleft license for -software and other kinds of works. + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to +our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. +software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you @@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. The precise terms and conditions for copying, distribution and modification follow. @@ -72,7 +60,7 @@ modification follow. 0. Definitions. - "This License" refers to version 3 of the GNU General Public License. + "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. @@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. - 13. Use with the GNU Affero General Public License. + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single +under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General +Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published +GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's +versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. @@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. + GNU Affero General Public License for more details. - You should have received a copy of the GNU General Public License + You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see +For more information on this, and how to apply and follow the GNU AGPL, see . - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/README.md b/README.md index 92ed0b9..dbf8ea6 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,47 @@ +# PLEASE DO NOT USE THIS, IT IS NOT COMPLETE AND IS AN ACTIVE WORK-IN-PROGRESS. ISSUES HAVE BEEN DISABLED FOR THIS REASON. +## Please join the Discord: https://discord.cs.surf + # Timer Core plugin for CS2 Surf Servers. This project is aimed to be fully open-source with the goal of uniting all of CS2 surf towards building the game mode. # Goals *Note: This is not definitive/complete and simply serves as a reference for what we should try to achieve. Subject to change.* +Bold & Italics = being worked on. -- [ ] Data storage +- [ ] Database - [ ] MySQL database schema ([W.I.P Design Diagram](https://dbdiagram.io/d/CS2Surf-Timer-DB-Schema-6560b76b3be1495787ace4d2)) - - [ ] Plugin auto-create tables for easier install? -- [ ] Zoning - - [ ] Hook zones from map triggers - - [ ] Support for stages/checkpoints - - [ ] Support for bonuses - - [ ] Load zone information for official maps from CS2 Surf upstream? (Probably make this optional) - - [ ] Support for custom zoning (Draw in-game similar to CSGO Surftimer?) + - [ ] Plugin auto-create tables for easier setup? + - [X] Base database class implementation +- [ ] Maps + - [X] Implement map info object (DB) + - [ ] Zoning + - [X] Start/End trigger touch hooks + - [X] Load zone information automatically from standardised triggers: https://github.com/CS2Surf/Timer/wiki/CS2-Surf-Mapping + - [ ] _**Support for stages (`/rs`, teleporting with `/s`)**_ + - [ ] _**Support for bonuses (`/rs`, teleporting with `/b #`)**_ + - [ ] _**Start/End touch hooks implemented for all zones**_ +- [ ] Surf configs + - [X] Server settings configuration + - [ ] Plugin configuration + - [X] Database configuration - [ ] Timing - - [ ] Implement timer HUD (similar to WST) - - [ ] Save/load times from the database - - [ ] Practice Mode + - [X] Base timer class implementation + - [X] Base timer HUD implementation + - [X] Prespeed measurement and display + - [ ] Save/load times + - [ ] **_Save/load map personal bests_** + - [ ] **_Save/load map checkpoints_** + - [ ] **_Save/load bonus personal bests_** + - [ ] **_Save/load stage personal bests_** + - [ ] Practice Mode implementation - [ ] Announce records to Discord + - [ ] Stretch goal: sub-tick timing - [ ] Player Data - - [ ] Profiles - - [ ] Points/Skill Groups -- [ ] Replays -- [ ] Angle style implementation -- [ ] Paint + - [X] Base player class + - [ ] Player stat classes + - [ ] Profile implementation (DB) + - [ ] Points/Skill Groups (DB) + - [ ] Player settings (DB) +- [ ] Run replays +- [ ] Style implementation (SW, HSW, BW) +- [ ] Paint (?) diff --git a/cfg/SurfTimer/database.json b/cfg/SurfTimer/database.json new file mode 100644 index 0000000..01d0f8d --- /dev/null +++ b/cfg/SurfTimer/database.json @@ -0,0 +1,8 @@ +{ + "host": "DATABASE_HOST_OR_IP", + "database": "DATABASE_NAME", + "user": "DATABASE_USERNAME", + "password": "DATABASE_PASSWORD", + "port": 3306, + "timeout": 0 +} \ No newline at end of file diff --git a/cfg/SurfTimer/server_settings.cfg b/cfg/SurfTimer/server_settings.cfg new file mode 100644 index 0000000..fd4abc3 --- /dev/null +++ b/cfg/SurfTimer/server_settings.cfg @@ -0,0 +1,70 @@ +// These are settings you would like to enforce on the server. The plugin +// tries to enforce this on the server when the map starts. + +sv_cheats 1 + +// Timelimit +mp_timelimit "30" +mp_roundtime "30" +mp_roundtime_defuse "30" +mp_roundtime_hostage "30" +mp_roundtime_deployment "0" +mp_freezetime 3 + +// Comms Settings +sv_alltalk 1 +sv_deadtalk 1 +sv_full_alltalk 1 + +// Movement Settings +sv_airaccelerate 150 +sv_gravity 800 +sv_friction 5.2 +sv_maxspeed 350 +sv_accelerate 10 +sv_enablebunnyhopping 1 +sv_autobunnyhopping 1 +sv_staminajumpcost 0 +sv_staminalandcost 0 + +// Player Settings +mp_spectators_max 64 +mp_humanteam ct +mp_disconnect_kills_players 1 +mp_solid_teammates 0 +mp_respawn_on_death_ct 1 +mp_respawn_on_death_t 1 +mp_ct_default_secondary weapon_usp_silencer +mp_t_default_secondary weapon_usp_silencer +mp_autoteambalance 0 +mp_limitteams 0 +mp_playercashawards 0 +mp_teamcashawards 0 +mp_death_drop_c4 1 +mp_death_drop_defuser 1 +mp_death_drop_grenade 1 +mp_death_drop_gun 1 +mp_drop_knife_enable 1 +mp_weapons_allow_map_placed 1 +sv_falldamage_scale 0 +mp_damage_scale_t_body 0 +mp_damage_scale_t_head 0 +mp_damage_scale_ct_body 0 +mp_damage_scale_ct_head 0 + +// Server Settings +sv_allow_votes 0 +mp_suicide_penalty 0 +mp_friendlyfire 0 +mp_ignore_round_win_conditions 0 +mp_round_restart_delay 0 +mp_warmuptime_all_players_connected 0 +mp_freezetime 0 +mp_team_intro_time 0 +mp_warmup_end +mp_warmuptime 0 +bot_quota 0 +sv_holiday_mode 0 +sv_party_mode 0 + +sv_cheats 0 diff --git a/cfg/SurfTimer/timer_settings.json b/cfg/SurfTimer/timer_settings.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/cfg/SurfTimer/timer_settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/data/GeoIP/GeoLite2-Country.mmdb b/data/GeoIP/GeoLite2-Country.mmdb new file mode 100644 index 0000000..b170f4b Binary files /dev/null and b/data/GeoIP/GeoLite2-Country.mmdb differ diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..544b7b4 --- /dev/null +++ b/lang/en.json @@ -0,0 +1,3 @@ +{ + +} \ No newline at end of file diff --git a/src/ST-Commands/MapCommands.cs b/src/ST-Commands/MapCommands.cs new file mode 100644 index 0000000..6ae6200 --- /dev/null +++ b/src/ST-Commands/MapCommands.cs @@ -0,0 +1,72 @@ +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Attributes.Registration; +using CounterStrikeSharp.API.Modules.Commands; +using CounterStrikeSharp.API.Modules.Utils; +using CounterStrikeSharp.API.Modules.Admin; + +namespace SurfTimer; + +public partial class SurfTimer +{ + // All map-related commands here + [ConsoleCommand("css_tier", "Display the current map tier.")] + [ConsoleCommand("css_mapinfo", "Display the current map tier.")] + [ConsoleCommand("css_mi", "Display the current map tier.")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void MapTier(CCSPlayerController? player, CommandInfo command) + { + if (player == null) + return; + + player.PrintToChat($"{PluginPrefix} {CurrentMap.Name} - {ChatColors.Green}Tier {CurrentMap.Tier}{ChatColors.Default} - {ChatColors.Yellow}{CurrentMap.Stages} Stages{ChatColors.Default}"); + return; + } + + [ConsoleCommand("css_triggers", "List all valid zone triggers in the map.")] + [RequiresPermissions("@css/root")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void Triggers(CCSPlayerController? player, CommandInfo command) + { + if (player == null) + return; + + IEnumerable triggers = Utilities.FindAllEntitiesByDesignerName("trigger_multiple"); + player.PrintToChat($"Count of triggers: {triggers.Count()}"); + foreach (CBaseTrigger trigger in triggers) + { + if (trigger.Entity!.Name != null) + { + player.PrintToChat($"Trigger -> Origin: {trigger.AbsOrigin}, Radius: {trigger.Collision.BoundingRadius}, Name: {trigger.Entity!.Name}"); + } + } + + player.PrintToChat($"Hooked Trigger -> Start -> {CurrentMap.StartZone} -> Angles {CurrentMap.StartZoneAngles}"); + player.PrintToChat($"Hooked Trigger -> End -> {CurrentMap.EndZone}"); + int i = 1; + foreach (Vector stage in CurrentMap.StageStartZone) + { + if (stage.X == 0 && stage.Y == 0 && stage.Z == 0) + continue; + else + { + player.PrintToChat($"Hooked Trigger -> Stage {i} -> {stage} -> Angles {CurrentMap.StageStartZoneAngles[i]}"); + i++; + } + } + + i = 1; + foreach (Vector bonus in CurrentMap.BonusStartZone) + { + if (bonus.X == 0 && bonus.Y == 0 && bonus.Z == 0) + continue; + else + { + player.PrintToChat($"Hooked Trigger -> Bonus {i} -> {bonus} -> Angles {CurrentMap.BonusStartZoneAngles[i]}"); + i++; + } + } + + return; + } +} \ No newline at end of file diff --git a/src/ST-Commands/PlayerCommands.cs b/src/ST-Commands/PlayerCommands.cs new file mode 100644 index 0000000..251a421 --- /dev/null +++ b/src/ST-Commands/PlayerCommands.cs @@ -0,0 +1,87 @@ +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Attributes.Registration; +using CounterStrikeSharp.API.Modules.Commands; +using CounterStrikeSharp.API.Modules.Admin; +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Modules.Utils; + +namespace SurfTimer; + +public partial class SurfTimer +{ + [ConsoleCommand("css_r", "Reset back to the start of the map.")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void PlayerReset(CCSPlayerController? player, CommandInfo command) + { + if (player == null) + return; + + // To-do: players[userid].Timer.Reset() -> teleport player + playerList[player.UserId ?? 0].Timer.Reset(); + if (CurrentMap.StartZone != new Vector(0,0,0)) + Server.NextFrame(() => player.PlayerPawn.Value!.Teleport(CurrentMap.StartZone, new QAngle(0,0,0), new Vector(0,0,0))); + return; + } + + [ConsoleCommand("css_rs", "Reset back to the start of the stage or bonus you're in.")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void PlayerResetStage(CCSPlayerController? player, CommandInfo command) + { + if (player == null) + return; + + // To-do: players[userid].Timer.Reset() -> teleport player + Player SurfPlayer = playerList[player.UserId ?? 0]; + if (SurfPlayer.Timer.Stage != 0 && CurrentMap.StageStartZone[SurfPlayer.Timer.Stage] != new Vector(0,0,0)) + Server.NextFrame(() => player.PlayerPawn.Value!.Teleport(CurrentMap.StageStartZone[SurfPlayer.Timer.Stage], CurrentMap.StageStartZoneAngles[SurfPlayer.Timer.Stage], new Vector(0,0,0))); + else // Reset back to map start + Server.NextFrame(() => player.PlayerPawn.Value!.Teleport(CurrentMap.StartZone, new QAngle(0,0,0), new Vector(0,0,0))); + return; + } + + [ConsoleCommand("css_s", "Teleport to a stage")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void PlayerGoToStage(CCSPlayerController? player, CommandInfo command) + { + if (player == null) + return; + + int stage = Int32.Parse(command.ArgByIndex(1)) - 1; + if (stage > CurrentMap.Stages - 1) + stage = CurrentMap.Stages - 1; + + // Must be 1 argument + if (command.ArgCount < 2 || stage < 0) + { + #if DEBUG + player.PrintToChat($"CS2 Surf DEBUG >> css_s >> Arg#: {command.ArgCount} >> Args: {Int32.Parse(command.ArgByIndex(1))}"); + #endif + + player.PrintToChat($"{PluginPrefix} {ChatColors.Red}Invalid arguments. Usage: {ChatColors.Green}!s "); + return; + } + + else if (CurrentMap.Stages <= 0) + { + player.PrintToChat($"{PluginPrefix} {ChatColors.Red}This map has no stages."); + return; + } + + if (CurrentMap.StageStartZone[stage] != new Vector(0,0,0)) + { + if (stage == 0) + Server.NextFrame(() => player.PlayerPawn.Value!.Teleport(CurrentMap.StartZone, CurrentMap.StartZoneAngles, new Vector(0,0,0))); + else + Server.NextFrame(() => player.PlayerPawn.Value!.Teleport(CurrentMap.StageStartZone[stage], CurrentMap.StageStartZoneAngles[stage], new Vector(0,0,0))); + + playerList[player.UserId ?? 0].Timer.Reset(); + playerList[player.UserId ?? 0].Timer.StageMode = true; + + // To-do: If you run this while you're in the start zone, endtouch for the start zone runs after you've teleported + // causing the timer to start. This needs to be fixed. + } + + else + player.PrintToChat($"{PluginPrefix} {ChatColors.Red}Invalid stage provided. Usage: {ChatColors.Green}!s "); + } +} \ No newline at end of file diff --git a/src/ST-DB/DB.cs b/src/ST-DB/DB.cs new file mode 100644 index 0000000..78ce507 --- /dev/null +++ b/src/ST-DB/DB.cs @@ -0,0 +1,79 @@ +namespace SurfTimer; + +using System.Runtime.CompilerServices; +using CounterStrikeSharp.API; +using MySqlConnector; // https://dev.mysql.com/doc/connector-net/en/connector-net-connections-string.html + +// This will have functions for DB access and query sending +internal class TimerDatabase +{ + private readonly MySqlConnection? _db; + private readonly string _connString = string.Empty; + + public TimerDatabase() + { + // Null'd + } + + + public TimerDatabase(string host, string database, string user, string password, int port, int timeout) + { + this._connString = $"server={host};user={user};password={password};database={database};port={port};connect timeout={timeout};"; + this._db = new MySqlConnection(this._connString); + this._db.Open(); + } + + public void Close() + { + if (this._db != null) + this._db!.Close(); + } + + public async Task Query(string query) + { + return await Task.Run(async () => + { + try + { + if (this._db == null) + { + throw new InvalidOperationException("Database connection is not open."); + } + + MySqlCommand cmd = new(query, this._db); + MySqlDataReader reader = await cmd.ExecuteReaderAsync(); + + return reader; + } + catch (Exception ex) + { + Console.WriteLine($"Error executing query: {ex.Message}"); + throw; + } + }); + } + + public async Task Write(string query) + { + return await Task.Run(async () => + { + try + { + if (this._db == null) + { + throw new InvalidOperationException("Database connection is not open."); + } + + MySqlCommand cmd = new(query, this._db); + int rowsAffected = await cmd.ExecuteNonQueryAsync(); + + return rowsAffected; + } + catch (Exception ex) + { + Console.WriteLine($"Error executing write operation: {ex.Message}"); + throw; + } + }); + } +} diff --git a/src/ST-Events/Players.cs b/src/ST-Events/Players.cs new file mode 100644 index 0000000..fe93343 --- /dev/null +++ b/src/ST-Events/Players.cs @@ -0,0 +1,137 @@ +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Attributes.Registration; +using CounterStrikeSharp.API.Modules.Utils; +using MySqlConnector; +using MaxMind.GeoIP2; + +namespace SurfTimer; + +public partial class SurfTimer +{ + [GameEventHandler] // Player Connect Event + public HookResult OnPlayerConnect(EventPlayerConnectFull @event, GameEventInfo info) + { + var player = @event.Userid; + #if DEBUG + Console.WriteLine($"CS2 Surf DEBUG >> OnPlayerConnect -> {player.PlayerName} / {player.UserId} / {player.SteamID}"); + #endif + + if (player.IsBot || !player.IsValid) + { + return HookResult.Continue; + } + else + { + int dbID, joinDate, lastSeen, connections; + string name, country; + + // GeoIP + DatabaseReader geoipDB = new DatabaseReader(PluginPath + "data/GeoIP/GeoLite2-Country.mmdb"); + if (geoipDB.Country(player.IpAddress!.Split(":")[0]).Country.IsoCode is not null) + { + country = geoipDB.Country(player.IpAddress!.Split(":")[0]).Country.IsoCode!; + #if DEBUG + Console.WriteLine($"CS2 Surf DEBUG >> OnPlayerConnect -> GeoIP -> {player.PlayerName} -> {player.IpAddress!.Split(":")[0]} -> {country}"); + #endif + } + else + country = "XX"; + geoipDB.Dispose(); + + // Load player data from database (or create an entry if first time connecting) + Task dbTask = DB.Query($"SELECT * FROM `Player` WHERE `steam_id` = {player.SteamID} LIMIT 1;"); + MySqlDataReader playerData = dbTask.Result; + if (playerData.HasRows && playerData.Read()) + { + // Player exists in database + dbID = playerData.GetInt32("id"); + name = playerData.GetString("name"); + if (country == "XX" && playerData.GetString("country") != "XX") + country = playerData.GetString("country"); + joinDate = playerData.GetInt32("join_date"); + lastSeen = playerData.GetInt32("last_seen"); + connections = playerData.GetInt32("connections"); + playerData.Close(); + + #if DEBUG + Console.WriteLine($"CS2 Surf DEBUG >> OnPlayerConnect -> Returning player {name} ({player.SteamID}) loaded from database with ID {dbID}"); + #endif + } + + else + { + playerData.Close(); + // Player does not exist in database + name = player.PlayerName; + joinDate = (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + lastSeen = joinDate; + connections = 1; + + // Write new player to database + Task newPlayerTask = DB.Write($"INSERT INTO `Player` (`name`, `steam_id`, `country`, `join_date`, `last_seen`, `connections`) VALUES ('{MySqlHelper.EscapeString(name)}', {player.SteamID}, '{country}', {joinDate}, {lastSeen}, {connections});"); + int newPlayerTaskRows = newPlayerTask.Result; + if (newPlayerTaskRows != 1) + throw new Exception($"CS2 Surf ERROR >> OnPlayerConnect -> Failed to write new player to database, this shouldnt happen. Player: {name} ({player.SteamID})"); + + // Get new player's database ID + Task newPlayerDataTask = DB.Query($"SELECT `id` FROM `Player` WHERE `steam_id` = {player.SteamID} LIMIT 1;"); + MySqlDataReader newPlayerData = newPlayerDataTask.Result; + if (newPlayerData.HasRows && newPlayerData.Read()) + { + #if DEBUG + // Iterate through data: + for (int i = 0; i < newPlayerData.FieldCount; i++) + { + Console.WriteLine($"CS2 Surf DEBUG >> OnPlayerConnect -> newPlayerData[{i}] = {newPlayerData.GetValue(i)}"); + } + #endif + dbID = newPlayerData.GetInt32("id"); + } + else + throw new Exception($"CS2 Surf ERROR >> OnPlayerConnect -> Failed to get new player's database ID after writing, this shouldnt happen. Player: {name} ({player.SteamID})"); + newPlayerData.Close(); + + #if DEBUG + Console.WriteLine($"CS2 Surf DEBUG >> OnPlayerConnect -> New player {name} ({player.SteamID}) added to database with ID {dbID}"); + #endif + } + PlayerProfile Profile = new PlayerProfile(dbID, name, player.SteamID, country, joinDate, lastSeen, connections); + + // Create Player object + playerList[player.UserId ?? 0] = new Player(player, + new CCSPlayer_MovementServices(player.PlayerPawn.Value!.MovementServices!.Handle), + Profile); + + // Print join messages + Server.PrintToChatAll($"{PluginPrefix} {ChatColors.Green}{player.PlayerName}{ChatColors.Default} has connected from {playerList[player.UserId ?? 0].Profile.Country}."); + Console.WriteLine($"[CS2 Surf] {player.PlayerName} has connected from {playerList[player.UserId ?? 0].Profile.Country}."); + return HookResult.Continue; + } + } + + [GameEventHandler] // Player Disconnect Event + public HookResult OnPlayerDisconnect(EventPlayerDisconnect @event, GameEventInfo info) + { + var player = @event.Userid; + + if (player.IsBot || !player.IsValid) + { + return HookResult.Continue; + } + + else + { + // Update data in Player DB table + Task updatePlayerTask = DB.Write($"UPDATE `Player` SET country = '{playerList[player.UserId ?? 0].Profile.Country}', `lastseen` = {(int)DateTimeOffset.UtcNow.ToUnixTimeSeconds()}, `connections` = `connections` + 1 WHERE `id` = {playerList[player.UserId ?? 0].Profile.ID} LIMIT 1;"); + if (updatePlayerTask.Result != 1) + throw new Exception($"CS2 Surf ERROR >> OnPlayerDisconnect -> Failed to update player data in database. Player: {player.PlayerName} ({player.SteamID})"); + + // Player disconnection to-do + + // Remove player data from playerList + playerList.Remove(player.UserId ?? 0); + return HookResult.Continue; + } + } +} \ No newline at end of file diff --git a/src/ST-Events/Tick.cs b/src/ST-Events/Tick.cs new file mode 100644 index 0000000..3a196fb --- /dev/null +++ b/src/ST-Events/Tick.cs @@ -0,0 +1,13 @@ +namespace SurfTimer; + +public partial class SurfTimer +{ + public void OnTick() + { + foreach (var player in playerList.Values) + { + player.Timer.Tick(); + player.HUD.Display(); + } + } +} \ No newline at end of file diff --git a/src/ST-Events/TriggerEndTouch.cs b/src/ST-Events/TriggerEndTouch.cs new file mode 100644 index 0000000..e36280a --- /dev/null +++ b/src/ST-Events/TriggerEndTouch.cs @@ -0,0 +1,63 @@ +using System.Text.RegularExpressions; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Modules.Utils; +using CounterStrikeSharp.API.Modules.Memory.DynamicFunctions; + +namespace SurfTimer; + +public partial class SurfTimer +{ + // Trigger end touch handler - CBaseTrigger_EndTouchFunc + internal HookResult OnTriggerEndTouch(DynamicHook handler) + { + CBaseTrigger trigger = handler.GetParam(0); + CBaseEntity entity = handler.GetParam(1); + CCSPlayerController client = new CCSPlayerController(new CCSPlayerPawn(entity.Handle).Controller.Value!.Handle); + + if (client.IsBot || !client.IsValid || client.UserId == -1 || !client.PawnIsAlive) + { + return HookResult.Continue; + } + + else + { + // Implement Trigger End Touch Here + Player player = playerList[client.UserId ?? 0]; + #if DEBUG + player.Controller.PrintToChat($"CS2 Surf DEBUG >> CBaseTrigger_EndTouchFunc -> {trigger.DesignerName} -> {trigger.Entity!.Name}"); + #endif + + if (trigger.Entity!.Name != null) + { + // Map start zones -- hook into map_start, (s)tage1_start + if (trigger.Entity.Name.Contains("map_start") || + trigger.Entity.Name.Contains("s1_start") || + trigger.Entity.Name.Contains("stage1_start")) + { + // MAP START ZONE + player.Timer.Start(); + + // Prespeed display + float velocity = (float)Math.Sqrt(player.Controller.PlayerPawn.Value!.AbsVelocity.X * player.Controller.PlayerPawn.Value!.AbsVelocity.X + + player.Controller.PlayerPawn.Value!.AbsVelocity.Y * player.Controller.PlayerPawn.Value!.AbsVelocity.Y + + player.Controller.PlayerPawn.Value!.AbsVelocity.Z * player.Controller.PlayerPawn.Value!.AbsVelocity.Z); + player.Controller.PrintToCenter($"Prespeed: {velocity.ToString("0")} u/s"); + + #if DEBUG + player.Controller.PrintToChat($"CS2 Surf DEBUG >> CBaseTrigger_{ChatColors.LightRed}EndTouchFunc{ChatColors.Default} -> {ChatColors.Green}Map Start Zone"); + #endif + } + + // Stage start zones -- hook into (s)tage#_start + else if (Regex.Match(trigger.Entity.Name, "^s([1-9][0-9]?|tage[1-9][0-9]?)_start$").Success) + { + #if DEBUG + player.Controller.PrintToChat($"CS2 Surf DEBUG >> CBaseTrigger_{ChatColors.LightRed}EndTouchFunc{ChatColors.Default} -> {ChatColors.Yellow}Stage {Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value} Start Zone"); + #endif + } + } + + return HookResult.Continue; + } + } +} \ No newline at end of file diff --git a/src/ST-Events/TriggerStartTouch.cs b/src/ST-Events/TriggerStartTouch.cs new file mode 100644 index 0000000..dee74ba --- /dev/null +++ b/src/ST-Events/TriggerStartTouch.cs @@ -0,0 +1,90 @@ +using System.Text.RegularExpressions; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Modules.Utils; +using CounterStrikeSharp.API.Modules.Memory.DynamicFunctions; + +namespace SurfTimer; + +public partial class SurfTimer +{ + // Trigger start touch handler - CBaseTrigger_StartTouchFunc + internal HookResult OnTriggerStartTouch(DynamicHook handler) + { + CBaseTrigger trigger = handler.GetParam(0); + CBaseEntity entity = handler.GetParam(1); + CCSPlayerController client = new CCSPlayerController(new CCSPlayerPawn(entity.Handle).Controller.Value!.Handle); + + if (client.IsBot || !client.IsValid) + { + return HookResult.Continue; + } + + else + { + // Implement Trigger Start Touch Here + Player player = playerList[client.UserId ?? 0]; + #if DEBUG + player.Controller.PrintToChat($"CS2 Surf DEBUG >> CBaseTrigger_StartTouchFunc -> {trigger.DesignerName} -> {trigger.Entity!.Name}"); + #endif + + if (trigger.Entity!.Name != null) + { + // Map end zones -- hook into map_end + if (trigger.Entity.Name == "map_end") + { + // MAP END ZONE + if (player.Timer.IsRunning) + { + player.Timer.Stop(); + if (player.Stats.PB[0,0] == 0 || player.Timer.Ticks < player.Stats.PB[0,0]) + player.Stats.PB[0,0] = player.Timer.Ticks; + player.Controller.PrintToChat($"{PluginPrefix} You finished the map in {player.HUD.FormatTime(player.Stats.PB[0,0])}!"); + // player.Timer.Reset(); + } + + #if DEBUG + player.Controller.PrintToChat($"CS2 Surf DEBUG >> CBaseTrigger_{ChatColors.Lime}StartTouchFunc{ChatColors.Default} -> {ChatColors.Red}Map Stop Zone"); + #endif + } + + // Map start zones -- hook into map_start, (s)tage1_start + else if (trigger.Entity.Name.Contains("map_start") || + trigger.Entity.Name.Contains("s1_start") || + trigger.Entity.Name.Contains("stage1_start")) + { + player.Timer.Reset(); + + #if DEBUG + player.Controller.PrintToChat($"CS2 Surf DEBUG >> CBaseTrigger_{ChatColors.Lime}StartTouchFunc{ChatColors.Default} -> {ChatColors.Green}Map Start Zone"); + // player.Controller.PrintToChat($"CS2 Surf DEBUG >> CBaseTrigger_StartTouchFunc -> KeyValues: {trigger.Entity.KeyValues3}"); + #endif + } + + // Stage start zones -- hook into (s)tage#_start + else if (Regex.Match(trigger.Entity.Name, "^s([1-9][0-9]?|tage[1-9][0-9]?)_start$").Success) + { + int stage = Int32.Parse(Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value) - 1; + player.Timer.Stage = stage; + + // To-do: checkpoint functionality because stages = checkpoints + + #if DEBUG + player.Controller.PrintToChat($"CS2 Surf DEBUG >> CBaseTrigger_{ChatColors.Lime}StartTouchFunc{ChatColors.Default} -> {ChatColors.Yellow}Stage {Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value} Start Zone"); + #endif + } + + // Map checkpoint zones -- hook into map_(c)heck(p)oint# + else if (Regex.Match(trigger.Entity.Name, "^map_c(p[1-9][0-9]?|heckpoint[1-9][0-9]?)$").Success) + { + // To-do: checkpoint functionality + + #if DEBUG + player.Controller.PrintToChat($"CS2 Surf DEBUG >> CBaseTrigger_{ChatColors.Lime}StartTouchFunc{ChatColors.Default} -> {ChatColors.LightBlue}Checkpoint {Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value} Zone"); + #endif + } + } + + return HookResult.Continue; + } + } +} \ No newline at end of file diff --git a/src/ST-Map/Map.cs b/src/ST-Map/Map.cs new file mode 100644 index 0000000..ac0ed32 --- /dev/null +++ b/src/ST-Map/Map.cs @@ -0,0 +1,159 @@ +using System.Text.RegularExpressions; +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Modules.Utils; +using MySqlConnector; + +namespace SurfTimer; + +public class Map +{ + // Map information + public int ID {get; set;} = 0; + public string Name {get; set;} = ""; + public string Author {get; set;} = ""; + public int Tier {get; set;} = 0; + public int Stages {get; set;} = 0; + public bool Ranked {get; set;} = false; + public int DateAdded {get; set;} = 0; + + // Zone Origin Information + // Map start/end zones + public Vector StartZone {get;} = new Vector(0,0,0); + public QAngle StartZoneAngles {get;} = new QAngle(0,0,0); + public Vector EndZone {get;} = new Vector(0,0,0); + // Map stage zones + public Vector[] StageStartZone {get;} = Enumerable.Repeat(0, 99).Select(x => new Vector(0,0,0)).ToArray(); + public QAngle[] StageStartZoneAngles {get;} = Enumerable.Repeat(0, 99).Select(x => new QAngle(0,0,0)).ToArray(); + // Map bonus zones + public Vector[] BonusStartZone {get;} = Enumerable.Repeat(0, 99).Select(x => new Vector(0,0,0)).ToArray(); // To-do: Implement bonuses + public QAngle[] BonusStartZoneAngles {get;} = Enumerable.Repeat(0, 99).Select(x => new QAngle(0,0,0)).ToArray(); // To-do: Implement bonuses + public Vector[] BonusEndZone {get;} = Enumerable.Repeat(0, 99).Select(x => new Vector(0,0,0)).ToArray(); // To-do: Implement bonuses + + // Constructor + internal Map(string Name, TimerDatabase DB) + { + // Gathering zones from the map + IEnumerable triggers = Utilities.FindAllEntitiesByDesignerName("trigger_multiple"); + // Gathering info_teleport_destinations from the map + IEnumerable teleports = Utilities.FindAllEntitiesByDesignerName("info_teleport_destination"); + foreach (CBaseTrigger trigger in triggers) + { + if (trigger.Entity!.Name != null) + { + // Map start zone + if (trigger.Entity!.Name.Contains("map_start") || + trigger.Entity!.Name.Contains("stage1_start") || + trigger.Entity!.Name.Contains("s1_start")) + { + this.StartZone = new Vector(trigger.AbsOrigin!.X, trigger.AbsOrigin!.Y, trigger.AbsOrigin!.Z); + foreach (CBaseEntity teleport in teleports) + { + if (teleport.Entity!.Name != null && IsInZone(trigger.AbsOrigin!, trigger.Collision.BoundingRadius, teleport.AbsOrigin!)) + { + this.StartZoneAngles = new QAngle(teleport.AbsRotation!.X, teleport.AbsRotation!.Y, teleport.AbsRotation!.Z); + } + } + } + + // Map end zone + else if (trigger.Entity!.Name.Contains("map_end")) + { + this.EndZone = new Vector(trigger.AbsOrigin!.X, trigger.AbsOrigin!.Y, trigger.AbsOrigin!.Z); + } + + // Stage start zones + else if (Regex.Match(trigger.Entity.Name, "^s([1-9][0-9]?|tage[1-9][0-9]?)_start$").Success) + { + this.StageStartZone[Int32.Parse(Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value) - 1] = new Vector(trigger.AbsOrigin!.X, trigger.AbsOrigin!.Y, trigger.AbsOrigin!.Z); + + // Find an info_destination_teleport inside this zone to grab angles from + foreach (CBaseEntity teleport in teleports) + { + if (teleport.Entity!.Name != null && IsInZone(trigger.AbsOrigin!, trigger.Collision.BoundingRadius, teleport.AbsOrigin!)) + { + this.StageStartZoneAngles[Int32.Parse(Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value) - 1] = new QAngle(teleport.AbsRotation!.X, teleport.AbsRotation!.Y, teleport.AbsRotation!.Z); + } + } + } + + else if (Regex.Match(trigger.Entity.Name, "^b([1-9][0-9]?|onus[1-9][0-9]?)_start$").Success) + { + this.BonusStartZone[Int32.Parse(Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value) - 1] = new Vector(trigger.AbsOrigin!.X, trigger.AbsOrigin!.Y, trigger.AbsOrigin!.Z); + + // Find an info_destination_teleport inside this zone to grab angles from + foreach (CBaseEntity teleport in teleports) + { + if (teleport.Entity!.Name != null && IsInZone(trigger.AbsOrigin!, trigger.Collision.BoundingRadius, teleport.AbsOrigin!)) + { + this.BonusStartZoneAngles[Int32.Parse(Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value) - 1] = new QAngle(teleport.AbsRotation!.X, teleport.AbsRotation!.Y, teleport.AbsRotation!.Z); + } + } + } + + else if (Regex.Match(trigger.Entity.Name, "^b([1-9][0-9]?|onus[1-9][0-9]?)_end$").Success) + { + this.BonusEndZone[Int32.Parse(Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value) - 1] = new Vector(trigger.AbsOrigin!.X, trigger.AbsOrigin!.Y, trigger.AbsOrigin!.Z); + } + } + } + Console.WriteLine($"[CS2 Surf] Identifying start zone: {this.StartZone.X},{this.StartZone.Y},{this.StartZone.Z}\nIdentifying end zone: {this.EndZone.X},{this.EndZone.Y},{this.EndZone.Z}"); + + // Gather map information OR create entry + Task reader = DB.Query($"SELECT * FROM Maps WHERE name='{MySqlHelper.EscapeString(Name)}'"); + MySqlDataReader mapData = reader.Result; + if (mapData.HasRows && mapData.Read()) + { + this.ID = mapData.GetInt32("id"); + this.Name = Name; + this.Author = mapData.GetString("author") ?? "Unknown"; + this.Tier = mapData.GetInt32("tier"); + this.Stages = mapData.GetInt32("stages"); + this.Ranked = mapData.GetBoolean("ranked"); + this.DateAdded = mapData.GetInt32("date_added"); + mapData.Close(); + } + + else + { + mapData.Close(); + Task writer = DB.Write($"INSERT INTO Maps (name, author, tier, stages, ranked, date_added, last_played) VALUES ('{MySqlHelper.EscapeString(Name)}', 'Unknown', 0, 0, 0, {(int)DateTimeOffset.UtcNow.ToUnixTimeSeconds()}, {(int)DateTimeOffset.UtcNow.ToUnixTimeSeconds()})"); + int writerRows = writer.Result; + if (writerRows != 1) + throw new Exception($"CS2 Surf ERROR >> OnRoundStart -> new Map() -> Failed to write new map to database, this shouldnt happen. Map: {Name}"); + + Task postWriteReader = DB.Query($"SELECT * FROM Maps WHERE name='{MySqlHelper.EscapeString(Name)}'"); + MySqlDataReader postWriteMapData = postWriteReader.Result; + if (postWriteMapData.HasRows && postWriteMapData.Read()) + { + this.ID = postWriteMapData.GetInt32("id"); + } + postWriteMapData.Close(); + this.Name = Name; + this.Author = "Unknown"; + this.Tier = 0; + this.Stages = 0; + this.Ranked = false; + this.DateAdded = (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + return; + } + + // Update the map's last played data in the DB + // Update last_played data + Task updater = DB.Write($"UPDATE Maps SET last_played={(int)DateTimeOffset.UtcNow.ToUnixTimeSeconds()} WHERE id = {this.ID}"); + int lastPlayedUpdateRows = updater.Result; + if (lastPlayedUpdateRows != 1) + throw new Exception($"CS2 Surf ERROR >> OnRoundStart -> update Map() -> Failed to update map in database, this shouldnt happen. Map: {Name}"); + } + + public bool IsInZone(Vector zoneOrigin, float zoneCollisionRadius, Vector spawnOrigin) + { + if (spawnOrigin.X >= zoneOrigin.X - zoneCollisionRadius && spawnOrigin.X <= zoneOrigin.X + zoneCollisionRadius && + spawnOrigin.Y >= zoneOrigin.Y - zoneCollisionRadius && spawnOrigin.Y <= zoneOrigin.Y + zoneCollisionRadius && + spawnOrigin.Z >= zoneOrigin.Z - zoneCollisionRadius && spawnOrigin.Z <= zoneOrigin.Z + zoneCollisionRadius) + return true; + else + return false; + } +} \ No newline at end of file diff --git a/src/ST-Player/Player.cs b/src/ST-Player/Player.cs new file mode 100644 index 0000000..3243b29 --- /dev/null +++ b/src/ST-Player/Player.cs @@ -0,0 +1,31 @@ +namespace SurfTimer; +using CounterStrikeSharp.API.Core; + +internal class Player +{ + // CCS requirements + public CCSPlayerController Controller {get;} + public CCSPlayer_MovementServices MovementServices {get;} // Can be used later for any movement modification (eg: styles) + + // Timer-related properties + public PlayerTimer Timer {get; set;} + public PlayerStats Stats {get; set;} + public PlayerHUD HUD {get; set;} + + // Player information + public PlayerProfile Profile {get; set;} + + // Constructor + public Player(CCSPlayerController Controller, CCSPlayer_MovementServices MovementServices, PlayerProfile Profile) + { + this.Controller = Controller; + this.MovementServices = MovementServices; + + this.Profile = Profile; + + this.Timer = new PlayerTimer(); + this.Stats = new PlayerStats(); + + this.HUD = new PlayerHUD(this); + } +} diff --git a/src/ST-Player/PlayerHUD.cs b/src/ST-Player/PlayerHUD.cs new file mode 100644 index 0000000..a92978c --- /dev/null +++ b/src/ST-Player/PlayerHUD.cs @@ -0,0 +1,71 @@ +namespace SurfTimer; + +internal class PlayerHUD +{ + private Player _player; + + public PlayerHUD(Player Player) + { + _player = Player; + } + + private string FormatHUDElementHTML(string title, string body, string color, string size = "m") + { + if (title != "") + { + if (size == "m") + return $"{title}: {body}"; + else + return $"{title}: {body}"; + } + + else + { + if (size == "m") + return $"{body}"; + else + return $"{body}"; + } + } + + public string FormatTime(int ticks) // https://github.com/DEAFPS/SharpTimer/blob/e4ef24fff29a33c36722d23961355742d507441f/Utils.cs#L38 + { + TimeSpan time = TimeSpan.FromSeconds(ticks / 64.0); + int millis = (int)(ticks % 64 * (1000.0 / 64.0)); + return $"{time.Minutes:D2}:{time.Seconds:D2}.{millis:D3}"; + } + + public void Display() + { + if (_player.Controller.IsValid && _player.Controller.PawnIsAlive) + { + // Timer Module + string timerColor = "#79d1ed"; + if (_player.Timer.IsRunning) + { + if (_player.Timer.PracticeMode) + timerColor = "#F2C94C"; + else + timerColor = "#2E9F65"; + } + string timerModule = FormatHUDElementHTML("", FormatTime(_player.Timer.Ticks), timerColor); + + // Velocity Module - To-do: Make velocity module configurable (XY or XYZ velocity) + float velocity = (float)Math.Sqrt(_player.Controller.PlayerPawn.Value!.AbsVelocity.X * _player.Controller.PlayerPawn.Value!.AbsVelocity.X + + _player.Controller.PlayerPawn.Value!.AbsVelocity.Y * _player.Controller.PlayerPawn.Value!.AbsVelocity.Y + + _player.Controller.PlayerPawn.Value!.AbsVelocity.Z * _player.Controller.PlayerPawn.Value!.AbsVelocity.Z); + string velocityModule = FormatHUDElementHTML("Speed", velocity.ToString("000"), "#79d1ed") + " u/s"; + // Rank Module + string rankModule = FormatHUDElementHTML("Rank", "N/A", "#7882dd"); // IMPLEMENT IN PlayerStats + // PB & WR Modules + string pbModule = FormatHUDElementHTML("PB", _player.Stats.PB[0,0] > 0 ? FormatTime(_player.Stats.PB[0,0]) : "N/A", "#7882dd"); // IMPLEMENT IN PlayerStats + string wrModule = FormatHUDElementHTML("WR", "N/A", "#7882dd"); // IMPLEMENT IN PlayerStats + + // Build HUD + string hud = $"{timerModule}
{velocityModule}
{pbModule} | {rankModule}
{wrModule}"; + + // Display HUD + _player.Controller.PrintToCenterHtml(hud); + } + } +} diff --git a/src/ST-Player/PlayerProfile.cs b/src/ST-Player/PlayerProfile.cs new file mode 100644 index 0000000..3904e35 --- /dev/null +++ b/src/ST-Player/PlayerProfile.cs @@ -0,0 +1,23 @@ +namespace SurfTimer; + +internal class PlayerProfile +{ + public int ID {get; set;} = 0; + public string Name {get; set;} = ""; + public ulong SteamID {get; set;} = 0; + public string Country {get; set;} = ""; + public int JoinDate {get; set;} = 0; + public int LastSeen {get; set;} = 0; + public int Connections {get; set;} = 0; + + public PlayerProfile(int ID, string Name, ulong SteamID, string Country, int JoinDate, int LastSeen, int Connections) + { + this.ID = ID; + this.Name = Name; + this.SteamID = SteamID; + this.Country = Country; + this.JoinDate = JoinDate; + this.LastSeen = LastSeen; + this.Connections = Connections; + } +} \ No newline at end of file diff --git a/src/ST-Player/PlayerStats.cs b/src/ST-Player/PlayerStats.cs new file mode 100644 index 0000000..4f9c005 --- /dev/null +++ b/src/ST-Player/PlayerStats.cs @@ -0,0 +1,14 @@ +namespace SurfTimer; + +internal class PlayerStats +{ + // To-Do: Each stat should be a class of its own, with its own methods and properties - easier to work with. + // Temporarily, we store ticks + basic info so we can experiment + + // These account for future style support and a relevant index. + public int[,] PB {get; set;} = {{0,0}}; // First dimension: style (0 = normal), second dimension: map/bonus (0 = map, 1+ = bonus index) + public int[,] Rank {get; set;} = {{0,0}}; // First dimension: style (0 = normal), second dimension: map/bonus (0 = map, 1+ = bonus index) + public int[,] Checkpoints {get; set;} = {{0,0}}; // First dimension: style (0 = normal), second dimension: checkpoint index + public int[,] StagePB {get; set;} = {{0,0}}; // First dimension: style (0 = normal), second dimension: stage index + public int[,] StageRank {get; set;} = {{0,0}}; // First dimension: style (0 = normal), second dimension: stage index +} \ No newline at end of file diff --git a/src/ST-Player/PlayerTimer.cs b/src/ST-Player/PlayerTimer.cs new file mode 100644 index 0000000..97df4f4 --- /dev/null +++ b/src/ST-Player/PlayerTimer.cs @@ -0,0 +1,59 @@ +namespace SurfTimer; + +internal class PlayerTimer +{ + // Status + public bool Enabled {get; set;} = true; // Enable toggle for entire timer + public bool Paused {get; set;} = false; // Pause toggle for timer + public bool IsRunning {get; set;} = false; // Is the timer currently running? + + // Modes + public bool PracticeMode {get; set;} = false; // Practice mode toggle + public bool StageMode {get; set;} = false; // Stage mode toggle + + // Tracking + public int Stage {get; set;} = 0; // Current stage tracker + public int Bonus {get; set;} = 0; // Current bonus tracker - To-do: bonus implementation + // public int Style = 0; // To-do: style implementation + + // Timing + public int Ticks {get; set;} = 0; // To-do: sub-tick counting? This currently goes on OnTick, which is not sub-tick I believe? Needs investigating + + // Methods + public void Reset() + { + this.Stop(); + this.Ticks = 0; + this.Stage = 0; + this.Paused = false; + this.PracticeMode = false; + } + + public void Pause() + { + this.Paused = true; + } + + public void Start() + { + // Timer Start method - notes: OnStartTimerPress + if (this.Enabled) + this.IsRunning = true; + } + + public void Stop() + { + // Timer Stop method - notes: OnStopTimerPress + this.IsRunning = false; + } + + public void Tick() + { + // Tick the timer - this checks for any restrictions, so can be conveniently called from anywhere + // without worry for any timing restrictions (eg: Paused, Enabled, etc) + if (this.Paused || !this.Enabled || !this.IsRunning) + return; + + this.Ticks++; + } +} \ No newline at end of file diff --git a/src/SurfTimer.cs b/src/SurfTimer.cs new file mode 100644 index 0000000..b81578a --- /dev/null +++ b/src/SurfTimer.cs @@ -0,0 +1,115 @@ +/* + ___ _____ _________ ___ + ___ / _/ |/ / __/ _ \/ _ | + ___ _/ // / _// , _/ __ | + ___ /___/_/|_/_/ /_/|_/_/ |_| + + Official Timer plugin for the CS2 Surf Initiative. + Copyright (C) 2024 Liam C. (Infra) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + + Source: https://github.com/CS2Surf/Timer +*/ + +#define DEBUG + +using System.Text.Json; +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Attributes; +using CounterStrikeSharp.API.Core.Attributes.Registration; +using CounterStrikeSharp.API.Modules.Memory; +using CounterStrikeSharp.API.Modules.Utils; + +namespace SurfTimer; + +// Gameplan: https://github.com/CS2Surf/Timer/tree/dev/README.md +[MinimumApiVersion(120)] +public partial class SurfTimer : BasePlugin +{ + // Metadata + public override string ModuleName => "CS2 SurfTimer"; + public override string ModuleVersion => "DEV-1"; + public override string ModuleDescription => "Official Surf Timer by the CS2 Surf Initiative."; + public override string ModuleAuthor => "The CS2 Surf Initiative - github.com/cs2surf"; + public string PluginPrefix => $"[{ChatColors.DarkBlue}CS2 Surf{ChatColors.Default}]"; // To-do: make configurable + + // Globals + private Dictionary playerList = new Dictionary(); // This can probably be done way better, revisit + internal TimerDatabase? DB = new TimerDatabase(); + public string PluginPath = Server.GameDirectory + "/csgo/addons/counterstrikesharp/plugins/SurfTimer/"; + internal Map CurrentMap = null!; + + /* ========== MAP START HOOKS ========== */ + public void OnMapStart(string mapName) + { + // Initialise Map Object + if ((CurrentMap == null || CurrentMap.Name != mapName) && mapName.Contains("surf_")) + { + AddTimer(3.0f, () => CurrentMap = new Map(mapName, DB!)); + } + } + + [GameEventHandler] + public HookResult OnRoundStart(EventRoundStart @event, GameEventInfo info) + { + // Load cvars/other configs here + // Execute server_settings.cfg + Server.ExecuteCommand("execifexists SurfTimer/server_settings.cfg"); + Console.WriteLine("[CS2 Surf] Executed configuration: server_settings.cfg"); + return HookResult.Continue; + } + + /* ========== PLUGIN LOAD ========== */ + public override void Load(bool hotReload) + { + // Load database config & spawn database object + try + { + JsonElement dbConfig = JsonDocument.Parse(File.ReadAllText(Server.GameDirectory + "/csgo/cfg/SurfTimer/database.json")).RootElement; + DB = new TimerDatabase(dbConfig.GetProperty("host").GetString()!, + dbConfig.GetProperty("database").GetString()!, + dbConfig.GetProperty("user").GetString()!, + dbConfig.GetProperty("password").GetString()!, + dbConfig.GetProperty("port").GetInt32(), + dbConfig.GetProperty("timeout").GetInt32()); + Console.WriteLine("[CS2 Surf] Database connection established."); + } + + catch (Exception e) + { + Console.WriteLine($"[CS2 Surf] Error loading database config: {e.Message}"); + // To-do: Abort plugin loading + } + + Console.WriteLine(String.Format(" ____________ ____ ___\n" + + " / ___/ __/_ | / __/_ ______/ _/\n" + + "/ /___\\ \\/ __/ _\\ \\/ // / __/ _/ \n" + + "\\___/___/____/ /___/\\_,_/_/ /_/\n" + + $"[CS2 Surf] SurfTimer plugin loaded. Version: {ModuleVersion}" + + $"[CS2 Surf] This plugin is licensed under the GNU Affero General Public License v3.0. See LICENSE for more information. Source code: https://github.com/CS2Surf/Timer\n" + )); + + // Map Start Hook + RegisterListener(OnMapStart); + // Tick listener + RegisterListener(OnTick); + + // StartTouch Hook + VirtualFunctions.CBaseTrigger_StartTouchFunc.Hook(OnTriggerStartTouch, HookMode.Post); + // EndTouch Hook + VirtualFunctions.CBaseTrigger_EndTouchFunc.Hook(OnTriggerEndTouch, HookMode.Post); + } +} diff --git a/src/SurfTimer.csproj b/src/SurfTimer.csproj index 40ec78f..8d66987 100644 --- a/src/SurfTimer.csproj +++ b/src/SurfTimer.csproj @@ -7,9 +7,9 @@ - - INSERT_CSSHARP_DLL_PATH_HERE - + + +