diff --git a/.gitignore b/.gitignore index 7812496..3485604 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ *.vs -*.vscode +*.vscode/* +!.vscode/tasks.json *.idea src/bin/Debug/* +src/bin/Release/* src/obj/* src/SurfTimer.csproj \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..68c000f --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,27 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build-debug", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/SurfTimer.csproj", + "/property:Configuration=Debug" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "build-release", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/SurfTimer.csproj", + "/property:Configuration=Release" + ], + "problemMatcher": "$msCompile" + } + ] +} diff --git a/README.md b/README.md index dbf8ea6..123870d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Bold & Italics = being worked on. - [ ] 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`)**_ + - [X] _**Support for stages (`/rs`, teleporting with `/s`)**_ - [ ] _**Support for bonuses (`/rs`, teleporting with `/b #`)**_ - [ ] _**Start/End touch hooks implemented for all zones**_ - [ ] Surf configs @@ -28,9 +28,9 @@ Bold & Italics = being worked on. - [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_** + - [X] Save/load times + - [X] **_Save/load map personal bests_** + - [X] **_Save/load map checkpoints_** - [ ] **_Save/load bonus personal bests_** - [ ] **_Save/load stage personal bests_** - [ ] Practice Mode implementation @@ -38,10 +38,11 @@ Bold & Italics = being worked on. - [ ] Stretch goal: sub-tick timing - [ ] Player Data - [X] Base player class - - [ ] Player stat classes + - [ ] **_Player stat classes_** - [ ] Profile implementation (DB) - [ ] Points/Skill Groups (DB) - [ ] Player settings (DB) -- [ ] Run replays +- [X] Run replays +- [X] Saveloc/Tele - [ ] Style implementation (SW, HSW, BW) - [ ] Paint (?) diff --git a/cfg/SurfTimer/server_settings.cfg b/cfg/SurfTimer/server_settings.cfg index fd4abc3..b67b24f 100644 --- a/cfg/SurfTimer/server_settings.cfg +++ b/cfg/SurfTimer/server_settings.cfg @@ -18,6 +18,7 @@ sv_full_alltalk 1 // Movement Settings sv_airaccelerate 150 +// sv_airaccelerate 2000 sv_gravity 800 sv_friction 5.2 sv_maxspeed 350 @@ -26,6 +27,16 @@ sv_enablebunnyhopping 1 sv_autobunnyhopping 1 sv_staminajumpcost 0 sv_staminalandcost 0 +sv_timebetweenducks 0 + +// Some replay bot shit (took so fucking long to debug) +// bot_quota 1 No need for this, because the server handles it +bot_quota_mode "normal" +bot_join_after_player 1 +bot_join_team CT +bot_zombie 1 +bot_stop 1 +bot_freeze 1 // Player Settings mp_spectators_max 64 @@ -36,8 +47,8 @@ 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_autoteambalance 0 mp_playercashawards 0 mp_teamcashawards 0 mp_death_drop_c4 1 @@ -63,8 +74,7 @@ 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 +sv_cheats 0 \ No newline at end of file diff --git a/src/ST-Commands/MapCommands.cs b/src/ST-Commands/MapCommands.cs index 6ae6200..4bebc09 100644 --- a/src/ST-Commands/MapCommands.cs +++ b/src/ST-Commands/MapCommands.cs @@ -19,7 +19,10 @@ 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}"); + if (CurrentMap.Stages > 1) + player.PrintToChat($"{PluginPrefix} {CurrentMap.Name} - {ChatColors.Green}Tier {CurrentMap.Tier}{ChatColors.Default} - Staged {ChatColors.Yellow}{CurrentMap.Stages} Stages{ChatColors.Default}"); + else + player.PrintToChat($"{PluginPrefix} {CurrentMap.Name} - {ChatColors.Green}Tier {CurrentMap.Tier}{ChatColors.Default} - Linear {ChatColors.Yellow}{CurrentMap.Checkpoints} Checkpoints{ChatColors.Default}"); return; } diff --git a/src/ST-Commands/PlayerCommands.cs b/src/ST-Commands/PlayerCommands.cs index 251a421..3e51527 100644 --- a/src/ST-Commands/PlayerCommands.cs +++ b/src/ST-Commands/PlayerCommands.cs @@ -18,8 +18,8 @@ public void PlayerReset(CCSPlayerController? player, CommandInfo command) // 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))); + 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; } @@ -32,10 +32,10 @@ public void PlayerResetStage(CCSPlayerController? player, CommandInfo command) // 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))); + 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))); + Server.NextFrame(() => player.PlayerPawn.Value!.Teleport(CurrentMap.StartZone, new QAngle(0, 0, 0), new Vector(0, 0, 0))); return; } @@ -47,7 +47,7 @@ public void PlayerGoToStage(CCSPlayerController? player, CommandInfo command) return; int stage = Int32.Parse(command.ArgByIndex(1)) - 1; - if (stage > CurrentMap.Stages - 1) + if (stage > CurrentMap.Stages - 1 && CurrentMap.Stages > 0) stage = CurrentMap.Stages - 1; // Must be 1 argument @@ -60,28 +60,243 @@ public void PlayerGoToStage(CCSPlayerController? player, CommandInfo command) 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 (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))); + 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))); - + 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; + playerList[player.UserId ?? 0].Timer.IsStageMode = 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 + else player.PrintToChat($"{PluginPrefix} {ChatColors.Red}Invalid stage provided. Usage: {ChatColors.Green}!s "); } + + [ConsoleCommand("css_spec", "Moves a player automaticlly into spectator mode")] + public void MovePlayerToSpectator(CCSPlayerController? player, CommandInfo command) + { + if (player == null || player.Team == CsTeam.Spectator) + return; + + player.ChangeTeam(CsTeam.Spectator); + } + + /* + ######################### + Reaplay Commands + ######################### + */ + [ConsoleCommand("css_replaybotpause", "Pause the replay bot playback")] + [ConsoleCommand("css_rbpause", "Pause the replay bot playback")] + public void PauseReplay(CCSPlayerController? player, CommandInfo command) + { + if(player == null || player.Team != CsTeam.Spectator) + return; + + foreach(ReplayPlayer rb in CurrentMap.ReplayBots) + { + if(!rb.IsPlayable || !rb.IsPlaying || !playerList[player.UserId ?? 0].IsSpectating(rb.Controller!)) + continue; + + rb.Pause(); + } + } + + [ConsoleCommand("css_replaybotflip", "Flips the replay bot between Forward/Backward playback")] + [ConsoleCommand("css_rbflip", "Flips the replay bot between Forward/Backward playback")] + public void ReverseReplay(CCSPlayerController? player, CommandInfo command) + { + if(player == null || player.Team != CsTeam.Spectator) + return; + + foreach(ReplayPlayer rb in CurrentMap.ReplayBots) + { + if(!rb.IsPlayable || !rb.IsPlaying || !playerList[player.UserId ?? 0].IsSpectating(rb.Controller!)) + continue; + + rb.FrameTickIncrement *= -1; + } + } + + [ConsoleCommand("css_pbreplay", "Allows for replay of player's PB")] + public void PbReplay(CCSPlayerController? player, CommandInfo command) + { + if(player == null) + return; + + int maptime_id = playerList[player!.UserId ?? 0].Stats.PB[playerList[player.UserId ?? 0].Timer.Style].ID; + if (command.ArgCount > 1) + { + try + { + maptime_id = int.Parse(command.ArgByIndex(1)); + } + catch {} + } + + if(maptime_id == -1 || !CurrentMap.ConnectedMapTimes.Contains(maptime_id)) + { + player.PrintToChat($"{PluginPrefix} {ChatColors.Red}No time was found"); + return; + } + + for(int i = 0; i < CurrentMap.ReplayBots.Count; i++) + { + if(CurrentMap.ReplayBots[i].Stat_MapTimeID == maptime_id) + { + player.PrintToChat($"{PluginPrefix} {ChatColors.Red}A bot of this run already playing"); + return; + } + } + + CurrentMap.ReplayBots = CurrentMap.ReplayBots.Prepend(new ReplayPlayer() { + Stat_MapTimeID = maptime_id, + Stat_Prefix = "PB" + }).ToList(); + + Server.NextFrame(() => { + Server.ExecuteCommand($"bot_quota {CurrentMap.ReplayBots.Count}"); + }); + } + + /* + ######################## + Saveloc Commands + ######################## + */ + [ConsoleCommand("css_saveloc", "Save current player location to be practiced")] + public void SavePlayerLocation(CCSPlayerController? player, CommandInfo command) + { + if(player == null || !player.PawnIsAlive || !playerList.ContainsKey(player.UserId ?? 0)) + return; + + Player p = playerList[player.UserId ?? 0]; + if (!p.Timer.IsRunning) + { + p.Controller.PrintToChat($"{PluginPrefix} {ChatColors.Red}Cannot save location while not in run"); + return; + } + + var player_pos = p.Controller.Pawn.Value!.AbsOrigin!; + var player_angle = p.Controller.PlayerPawn.Value!.EyeAngles; + var player_velocity = p.Controller.PlayerPawn.Value!.AbsVelocity; + + p.SavedLocations.Add(new SavelocFrame { + Pos = new Vector(player_pos.X, player_pos.Y, player_pos.Z), + Ang = new QAngle(player_angle.X, player_angle.Y, player_angle.Z), + Vel = new Vector(player_velocity.X, player_velocity.Y, player_velocity.Z), + Tick = p.Timer.Ticks + }); + p.CurrentSavedLocation = p.SavedLocations.Count-1; + + p.Controller.PrintToChat($"{PluginPrefix} {ChatColors.Green}Saved location! {ChatColors.Default} use !tele {p.SavedLocations.Count-1} to teleport to this location"); + } + + [ConsoleCommand("css_tele", "Teleport player to current saved location")] + public void TeleportPlayerLocation(CCSPlayerController? player, CommandInfo command) + { + if(player == null || !player.PawnIsAlive || !playerList.ContainsKey(player.UserId ?? 0)) + return; + + Player p = playerList[player.UserId ?? 0]; + + if(p.SavedLocations.Count == 0) + { + p.Controller.PrintToChat($"{PluginPrefix} {ChatColors.Red}No saved locations"); + return; + } + + if(!p.Timer.IsRunning) + p.Timer.Start(); + + if (!p.Timer.IsPracticeMode) + { + p.Controller.PrintToChat($"{PluginPrefix} {ChatColors.Red}Timer now on practice"); + p.Timer.IsPracticeMode = true; + } + + if(command.ArgCount > 1) + try + { + int tele_n = int.Parse(command.ArgByIndex(1)); + if (tele_n < p.SavedLocations.Count) + p.CurrentSavedLocation = tele_n; + } + catch { } + SavelocFrame location = p.SavedLocations[p.CurrentSavedLocation]; + Server.NextFrame(() => { + p.Controller.PlayerPawn.Value!.Teleport(location.Pos, location.Ang, location.Vel); + p.Timer.Ticks = location.Tick; + }); + + p.Controller.PrintToChat($"{PluginPrefix} Teleported #{p.CurrentSavedLocation}"); + } + + [ConsoleCommand("css_teleprev", "Teleport player to previous saved location")] + public void TeleportPlayerLocationPrev(CCSPlayerController? player, CommandInfo command) + { + if(player == null || !player.PawnIsAlive || !playerList.ContainsKey(player.UserId ?? 0)) + return; + + Player p = playerList[player.UserId ?? 0]; + + if(p.SavedLocations.Count == 0) + { + p.Controller.PrintToChat($"{PluginPrefix} {ChatColors.Red}No saved locations"); + return; + } + + if(p.CurrentSavedLocation == 0) + { + p.Controller.PrintToChat($"{PluginPrefix} {ChatColors.Red}Already at first location"); + } + else + { + p.CurrentSavedLocation--; + } + + TeleportPlayerLocation(player, command); + + p.Controller.PrintToChat($"{PluginPrefix} Teleported #{p.CurrentSavedLocation}"); + } + + [ConsoleCommand("css_telenext", "Teleport player to next saved location")] + public void TeleportPlayerLocationNext(CCSPlayerController? player, CommandInfo command) + { + if(player == null || !player.PawnIsAlive || !playerList.ContainsKey(player.UserId ?? 0)) + return; + + Player p = playerList[player.UserId ?? 0]; + + if(p.SavedLocations.Count == 0) + { + p.Controller.PrintToChat($"{PluginPrefix} {ChatColors.Red}No saved locations"); + return; + } + + if(p.CurrentSavedLocation == p.SavedLocations.Count-1) + { + p.Controller.PrintToChat($"{PluginPrefix} {ChatColors.Red}Already at last location"); + } + else + { + p.CurrentSavedLocation++; + } + + TeleportPlayerLocation(player, command); + + p.Controller.PrintToChat($"{PluginPrefix} Teleported #{p.CurrentSavedLocation}"); + } } \ No newline at end of file diff --git a/src/ST-Events/Players.cs b/src/ST-Events/Players.cs index 011cbaf..a2162a2 100644 --- a/src/ST-Events/Players.cs +++ b/src/ST-Events/Players.cs @@ -9,15 +9,51 @@ namespace SurfTimer; public partial class SurfTimer { - [GameEventHandler] // Player Connect Event - public HookResult OnPlayerConnect(EventPlayerConnectFull @event, GameEventInfo info) + [GameEventHandler] + public HookResult OnPlayerSpawn(EventPlayerSpawn @event, GameEventInfo info) + { + var controller = @event.Userid; + if(!controller.IsValid || !controller.IsBot) + return HookResult.Continue; + + for (int i = 0; i < CurrentMap.ReplayBots.Count; i++) + { + if(CurrentMap.ReplayBots[i].IsPlayable) + continue; + + int repeats = -1; + if(CurrentMap.ReplayBots[i].Stat_Prefix == "PB") + repeats = 3; + + CurrentMap.ReplayBots[i].SetController(controller, repeats); + Server.PrintToChatAll($"{ChatColors.Lime} Loading replay data..."); + AddTimer(2f, () => { + if(!CurrentMap.ReplayBots[i].IsPlayable) + return; + + CurrentMap.ReplayBots[i].Controller!.RemoveWeapons(); + + CurrentMap.ReplayBots[i].LoadReplayData(DB!); + + CurrentMap.ReplayBots[i].Start(); + }); + + return HookResult.Continue; + } + + return HookResult.Continue; + } + + [GameEventHandler] + public HookResult OnPlayerConnectFull(EventPlayerConnectFull @event, GameEventInfo info) { var player = @event.Userid; #if DEBUG Console.WriteLine($"CS2 Surf DEBUG >> OnPlayerConnect -> {player.PlayerName} / {player.UserId} / {player.SteamID}"); + Console.WriteLine($"CS2 Surf DEBUG >> OnPlayerConnect -> {player.PlayerName} / {player.UserId} / Bot Diff: {player.PawnBotDifficulty}"); #endif - if (player.IsBot || !player.IsValid) + if (player.IsBot || !player.IsValid) // IsBot might be broken so we can check for PawnBotDifficulty which is `-1` for real players { return HookResult.Continue; } @@ -39,7 +75,10 @@ public HookResult OnPlayerConnect(EventPlayerConnectFull @event, GameEventInfo i country = "XX"; geoipDB.Dispose(); - // Load player data from database (or create an entry if first time connecting) + if (DB == null) + throw new Exception("CS2 Surf ERROR >> OnPlayerConnect -> DB object is null, this shouldnt happen."); + + // Load player profile 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()) @@ -58,7 +97,6 @@ public HookResult OnPlayerConnect(EventPlayerConnectFull @event, GameEventInfo i Console.WriteLine($"CS2 Surf DEBUG >> OnPlayerConnect -> Returning player {name} ({player.SteamID}) loaded from database with ID {dbID}"); #endif } - else { playerData.Close(); @@ -69,11 +107,15 @@ public HookResult OnPlayerConnect(EventPlayerConnectFull @event, GameEventInfo i 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});"); + 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})"); - + newPlayerTask.Dispose(); + // 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; @@ -96,15 +138,25 @@ public HookResult OnPlayerConnect(EventPlayerConnectFull @event, GameEventInfo i 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); + dbTask.Dispose(); - // Create Player object + // Create Player object and add to playerList + PlayerProfile Profile = new PlayerProfile(dbID, name, player.SteamID, country, joinDate, lastSeen, connections); playerList[player.UserId ?? 0] = new Player(player, new CCSPlayer_MovementServices(player.PlayerPawn.Value!.MovementServices!.Handle), - Profile); + Profile, CurrentMap); + #if DEBUG + Console.WriteLine($"=================================== SELECT * FROM `MapTimes` WHERE `player_id` = {playerList[player.UserId ?? 0].Profile.ID} AND `map_id` = {CurrentMap.ID};"); + #endif + + // To-do: hardcoded Style value + // Load MapTimes for the player's PB and their Checkpoints + playerList[player.UserId ?? 0].Stats.LoadMapTimesData(playerList[player.UserId ?? 0], DB); // Will reload PB and Checkpoints for the player for all styles + playerList[player.UserId ?? 0].Stats.LoadCheckpointsData(DB); // To-do: This really should go inside `LoadMapTimesData` imo cuz here we hardcoding load for Style 0 + // Print join messages - Server.PrintToChatAll($"{PluginPrefix} {ChatColors.Green}{player.PlayerName}{ChatColors.Default} has connected from {playerList[player.UserId ?? 0].Profile.Country}."); + Server.PrintToChatAll($"{PluginPrefix} {ChatColors.Green}{player.PlayerName}{ChatColors.Default} has connected from {ChatColors.Lime}{playerList[player.UserId ?? 0].Profile.Country}{ChatColors.Default}."); Console.WriteLine($"[CS2 Surf] {player.PlayerName} has connected from {playerList[player.UserId ?? 0].Profile.Country}."); return HookResult.Continue; } @@ -115,6 +167,10 @@ public HookResult OnPlayerDisconnect(EventPlayerDisconnect @event, GameEventInfo { var player = @event.Userid; + for (int i = 0; i < CurrentMap.ReplayBots.Count; i++) + if (CurrentMap.ReplayBots[i].IsPlayable && CurrentMap.ReplayBots[i].Controller!.Equals(player)) + CurrentMap.ReplayBots[i].Reset(); + if (player.IsBot || !player.IsValid) { return HookResult.Continue; @@ -122,15 +178,29 @@ public HookResult OnPlayerDisconnect(EventPlayerDisconnect @event, GameEventInfo else { - // Update data in Player DB table - Task updatePlayerTask = DB.Write($"UPDATE `Player` SET country = '{playerList[player.UserId ?? 0].Profile.Country}', `last_seen` = {(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})"); + if (DB == null) + throw new Exception("CS2 Surf ERROR >> OnPlayerDisconnect -> DB object is null, this shouldnt happen."); - // Player disconnection to-do + if (!playerList.ContainsKey(player.UserId ?? 0)) + { + Console.WriteLine($"CS2 Surf ERROR >> OnPlayerDisconnect -> Player playerList does NOT contain player.UserId, this shouldn't happen. Player: {player.PlayerName} ({player.UserId})"); + } + else + { + // Update data in Player DB table + Task updatePlayerTask = DB.Write($@" + UPDATE `Player` SET country = '{playerList[player.UserId ?? 0].Profile.Country}', + `last_seen` = {(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 + updatePlayerTask.Dispose(); - // Remove player data from playerList - playerList.Remove(player.UserId ?? 0); + // Remove player data from playerList + playerList.Remove(player.UserId ?? 0); + } return HookResult.Continue; } } diff --git a/src/ST-Events/Tick.cs b/src/ST-Events/Tick.cs index 3a196fb..37703e4 100644 --- a/src/ST-Events/Tick.cs +++ b/src/ST-Events/Tick.cs @@ -1,3 +1,5 @@ +using CounterStrikeSharp.API.Modules.Cvars; + namespace SurfTimer; public partial class SurfTimer @@ -7,7 +9,29 @@ public void OnTick() foreach (var player in playerList.Values) { player.Timer.Tick(); + player.ReplayRecorder.Tick(player); player.HUD.Display(); } + + if (CurrentMap == null) + return; + + // Need to disable maps from executing their cfgs. Currently idk how (But seriusly it a security issue) + ConVar? bot_quota = ConVar.Find("bot_quota"); + if (bot_quota != null) + { + int cbq = bot_quota.GetPrimitiveValue(); + if(cbq != CurrentMap.ReplayBots.Count) + { + bot_quota.SetValue(CurrentMap.ReplayBots.Count); + } + } + + for(int i = 0; i < CurrentMap!.ReplayBots.Count; i++) + { + CurrentMap.ReplayBots[i].Tick(); + if (CurrentMap.ReplayBots[i].RepeatCount == 0) + CurrentMap.KickReplayBot(i); + } } } \ No newline at end of file diff --git a/src/ST-Events/TriggerEndTouch.cs b/src/ST-Events/TriggerEndTouch.cs index e36280a..b98ed13 100644 --- a/src/ST-Events/TriggerEndTouch.cs +++ b/src/ST-Events/TriggerEndTouch.cs @@ -13,8 +13,7 @@ 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) + if (!client.IsValid || client.UserId == -1 || !client.PawnIsAlive || !playerList.ContainsKey((int)client.UserId!)) // `client.IsBot` throws error in server console when going to spectator? + !playerList.ContainsKey((int)client.UserId!) make sure to not check for user_id that doesnt exists { return HookResult.Continue; } @@ -29,19 +28,47 @@ internal HookResult OnTriggerEndTouch(DynamicHook handler) if (trigger.Entity!.Name != null) { + // Get velocities for DB queries + // Get the velocity of the player - we will be using this values to compare and write to DB + float velocity_x = player.Controller.PlayerPawn.Value!.AbsVelocity.X; + float velocity_y = player.Controller.PlayerPawn.Value!.AbsVelocity.Y; + float velocity_z = player.Controller.PlayerPawn.Value!.AbsVelocity.Z; + float velocity = (float)Math.Sqrt(velocity_x * velocity_x + velocity_y * velocity_y + velocity_z + velocity_z); + // 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")) { + // Replay + if(player.ReplayRecorder.IsRecording) + { + // Saveing 2 seconds before leaving the start zone + player.ReplayRecorder.Frames.RemoveRange(0, Math.Max(0, player.ReplayRecorder.Frames.Count - (64*2))); // Todo make a plugin convar for the time saved before start of run + } + // MAP START ZONE player.Timer.Start(); + player.ReplayRecorder.CurrentSituation = ReplayFrameSituation.START_RUN; + + /* Revisit + // Wonky Prespeed check + // To-do: make the teleportation a bit more elegant (method in a class or something) + if (velocity > 666.0) + { + player.Controller.PrintToChat( + $"{PluginPrefix} {ChatColors.Red}You are going too fast! ({velocity.ToString("0")} u/s)"); + player.Timer.Reset(); + if (CurrentMap.StartZone != new Vector(0,0,0)) + Server.NextFrame(() => player.Controller.PlayerPawn.Value!.Teleport(CurrentMap.StartZone, new QAngle(0,0,0), new Vector(0,0,0))); + } + */ // 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"); + player.Stats.ThisRun.StartVelX = velocity_x; // Start pre speed for the run + player.Stats.ThisRun.StartVelY = velocity_y; // Start pre speed for the run + player.Stats.ThisRun.StartVelZ = velocity_z; // Start pre speed for the run #if DEBUG player.Controller.PrintToChat($"CS2 Surf DEBUG >> CBaseTrigger_{ChatColors.LightRed}EndTouchFunc{ChatColors.Default} -> {ChatColors.Green}Map Start Zone"); @@ -53,7 +80,72 @@ internal HookResult OnTriggerEndTouch(DynamicHook handler) { #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"); + Console.WriteLine($"===================== player.Timer.Checkpoint {player.Timer.Checkpoint} - player.Stats.ThisRun.Checkpoint.Count {player.Stats.ThisRun.Checkpoint.Count}"); + #endif + + // This will populate the End velocities for the given Checkpoint zone (Stage = Checkpoint when in a Map Run) + if (player.Timer.Checkpoint != 0 && player.Timer.Checkpoint <= player.Stats.ThisRun.Checkpoint.Count) + { + var currentCheckpoint = player.Stats.ThisRun.Checkpoint[player.Timer.Checkpoint]; + #if DEBUG + Console.WriteLine($"currentCheckpoint.EndVelX {currentCheckpoint.EndVelX} - velocity_x {velocity_x}"); + Console.WriteLine($"currentCheckpoint.EndVelY {currentCheckpoint.EndVelY} - velocity_y {velocity_y}"); + Console.WriteLine($"currentCheckpoint.EndVelZ {currentCheckpoint.EndVelZ} - velocity_z {velocity_z}"); + Console.WriteLine($"currentCheckpoint.Attempts {currentCheckpoint.Attempts}"); + #endif + + // Update the values + currentCheckpoint.EndVelX = velocity_x; + currentCheckpoint.EndVelY = velocity_y; + currentCheckpoint.EndVelZ = velocity_z; + currentCheckpoint.EndTouch = player.Timer.Ticks; // To-do: what type of value we store in DB ? + currentCheckpoint.Attempts += 1; + // Assign the updated currentCheckpoint back to the list as `currentCheckpoint` is supposedly a copy of the original object + player.Stats.ThisRun.Checkpoint[player.Timer.Checkpoint] = currentCheckpoint; + + // Show Prespeed for stages - will be enabled/disabled by the user? + player.Controller.PrintToCenter($"Stage {Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value} - Prespeed: {velocity.ToString("0")} u/s"); + } + else + { + // Handle the case where the index is out of bounds + } + } + + // Checkpoint zones -- hook into "^map_c(p[1-9][0-9]?|heckpoint[1-9][0-9]?)$" 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) + { + #if DEBUG + player.Controller.PrintToChat($"CS2 Surf DEBUG >> CBaseTrigger_{ChatColors.LightRed}EndTouchFunc{ChatColors.Default} -> {ChatColors.Yellow}Checkpoint {Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value} Start Zone"); + Console.WriteLine($"===================== player.Timer.Checkpoint {player.Timer.Checkpoint} - player.Stats.ThisRun.Checkpoint.Count {player.Stats.ThisRun.Checkpoint.Count}"); #endif + + // This will populate the End velocities for the given Checkpoint zone (Stage = Checkpoint when in a Map Run) + if (player.Timer.Checkpoint != 0 && player.Timer.Checkpoint <= player.Stats.ThisRun.Checkpoint.Count) + { + var currentCheckpoint = player.Stats.ThisRun.Checkpoint[player.Timer.Checkpoint]; + #if DEBUG + Console.WriteLine($"currentCheckpoint.EndVelX {currentCheckpoint.EndVelX} - velocity_x {velocity_x}"); + Console.WriteLine($"currentCheckpoint.EndVelY {currentCheckpoint.EndVelY} - velocity_y {velocity_y}"); + Console.WriteLine($"currentCheckpoint.EndVelZ {currentCheckpoint.EndVelZ} - velocity_z {velocity_z}"); + #endif + + // Update the values + currentCheckpoint.EndVelX = velocity_x; + currentCheckpoint.EndVelY = velocity_y; + currentCheckpoint.EndVelZ = velocity_z; + currentCheckpoint.EndTouch = player.Timer.Ticks; // To-do: what type of value we store in DB ? + currentCheckpoint.Attempts += 1; + // Assign the updated currentCheckpoint back to the list as `currentCheckpoint` is supposedly a copy of the original object + player.Stats.ThisRun.Checkpoint[player.Timer.Checkpoint] = currentCheckpoint; + + // Show Prespeed for stages - will be enabled/disabled by the user? + player.Controller.PrintToCenter($"Checkpoint {Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value} - Prespeed: {velocity.ToString("0")} u/s"); + } + else + { + // Handle the case where the index is out of bounds + } } } diff --git a/src/ST-Events/TriggerStartTouch.cs b/src/ST-Events/TriggerStartTouch.cs index dee74ba..3059d4e 100644 --- a/src/ST-Events/TriggerStartTouch.cs +++ b/src/ST-Events/TriggerStartTouch.cs @@ -2,6 +2,7 @@ using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Modules.Utils; using CounterStrikeSharp.API.Modules.Memory.DynamicFunctions; +using CounterStrikeSharp.API; namespace SurfTimer; @@ -13,14 +14,19 @@ 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) + if (!client.IsValid || !client.PawnIsAlive || !playerList.ContainsKey((int)client.UserId!)) // !playerList.ContainsKey((int)client.UserId!) make sure to not check for user_id that doesnt exists { return HookResult.Continue; } - - else + else { + // To-do: Sometimes this triggers before `OnPlayerConnect` and `playerList` does not contain the player how is this possible :thonk: + if (!playerList.ContainsKey(client.UserId ?? 0)) + { + Console.WriteLine($"CS2 Surf ERROR >> OnTriggerStartTouch -> Init -> Player playerList does NOT contain client.UserId, this shouldn't happen. Player: {client.PlayerName} ({client.UserId})"); + throw new Exception($"CS2 Surf ERROR >> OnTriggerStartTouch -> Init -> Player playerList does NOT contain client.UserId, this shouldn't happen. Player: {client.PlayerName} ({client.UserId})"); + // return HookResult.Continue; + } // Implement Trigger Start Touch Here Player player = playerList[client.UserId ?? 0]; #if DEBUG @@ -29,17 +35,86 @@ internal HookResult OnTriggerStartTouch(DynamicHook handler) if (trigger.Entity!.Name != null) { + // Get velocities for DB queries + // Get the velocity of the player - we will be using this values to compare and write to DB + float velocity_x = player.Controller.PlayerPawn.Value!.AbsVelocity.X; + float velocity_y = player.Controller.PlayerPawn.Value!.AbsVelocity.Y; + float velocity_z = player.Controller.PlayerPawn.Value!.AbsVelocity.Z; + float velocity = (float)Math.Sqrt(velocity_x * velocity_x + velocity_y * velocity_y + velocity_z + velocity_z); + int style = player.Timer.Style; + // Map end zones -- hook into map_end if (trigger.Entity.Name == "map_end") { + player.Controller.PrintToCenter($"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(); + player.ReplayRecorder.CurrentSituation = ReplayFrameSituation.END_RUN; + + player.Stats.ThisRun.Ticks = player.Timer.Ticks; // End time for the run + player.Stats.ThisRun.EndVelX = velocity_x; // End pre speed for the run + player.Stats.ThisRun.EndVelY = velocity_y; // End pre speed for the run + player.Stats.ThisRun.EndVelZ = velocity_z; // End pre speed for the run + + string PracticeString = ""; + if (player.Timer.IsPracticeMode) + PracticeString = $"({ChatColors.Grey}Practice{ChatColors.Default}) "; + + // To-do: make Style (currently 0) be dynamic + if (player.Stats.PB[style].Ticks <= 0) // Player first ever PersonalBest for the map + { + Server.PrintToChatAll($"{PluginPrefix} {PracticeString}{player.Controller.PlayerName} finished the map in {ChatColors.Gold}{PlayerHUD.FormatTime(player.Timer.Ticks)}{ChatColors.Default} ({player.Timer.Ticks})!"); + } + else if (player.Timer.Ticks < player.Stats.PB[style].Ticks) // Player beating their existing PersonalBest for the map + { + Server.PrintToChatAll($"{PluginPrefix} {PracticeString}{ChatColors.Lime}{player.Profile.Name}{ChatColors.Default} beat their PB in {ChatColors.Gold}{PlayerHUD.FormatTime(player.Timer.Ticks)}{ChatColors.Default} (Old: {ChatColors.BlueGrey}{PlayerHUD.FormatTime(player.Stats.PB[style].Ticks)}{ChatColors.Default})!"); + } + else // Player did not beat their existing PersonalBest for the map + { + player.Controller.PrintToChat($"{PluginPrefix} {PracticeString}You finished the map in {ChatColors.Yellow}{PlayerHUD.FormatTime(player.Timer.Ticks)}{ChatColors.Default}!"); + return HookResult.Continue; // Exit here so we don't write to DB + } + + if (DB == null) + throw new Exception("CS2 Surf ERROR >> OnTriggerStartTouch (Map end zone) -> DB object is null, this shouldn't happen."); + + + player.Stats.PB[style].Ticks = player.Timer.Ticks; // Reload the run_time for the HUD and also assign for the DB query + + #if DEBUG + Console.WriteLine($@"CS2 Surf DEBUG >> OnTriggerStartTouch (Map end zone) -> + ============== INSERT INTO `MapTimes` + (`player_id`, `map_id`, `style`, `type`, `stage`, `run_time`, `start_vel_x`, `start_vel_y`, `start_vel_z`, `end_vel_x`, `end_vel_y`, `end_vel_z`, `run_date`) + VALUES ({player.Profile.ID}, {CurrentMap.ID}, {style}, 0, 0, {player.Stats.ThisRun.Ticks}, + {player.Stats.ThisRun.StartVelX}, {player.Stats.ThisRun.StartVelY}, {player.Stats.ThisRun.StartVelZ}, {velocity_x}, {velocity_y}, {velocity_z}, {(int)DateTimeOffset.UtcNow.ToUnixTimeSeconds()}) + ON DUPLICATE KEY UPDATE run_time=VALUES(run_time), start_vel_x=VALUES(start_vel_x), start_vel_y=VALUES(start_vel_y), + start_vel_z=VALUES(start_vel_z), end_vel_x=VALUES(end_vel_x), end_vel_y=VALUES(end_vel_y), end_vel_z=VALUES(end_vel_z), run_date=VALUES(run_date); + "); + #endif + + // Add entry in DB for the run + if(!player.Timer.IsPracticeMode) { + AddTimer(1.5f, () => { + player.Stats.ThisRun.SaveMapTime(player, DB); // Save the MapTime PB data + player.Stats.LoadMapTimesData(player, DB); // Load the MapTime PB data again (will refresh the MapTime ID for the Checkpoints query) + player.Stats.ThisRun.SaveCurrentRunCheckpoints(player, DB); // Save this run's checkpoints + player.Stats.LoadCheckpointsData(DB); // Reload checkpoints for the run - we should really have this in `SaveMapTime` as well but we don't re-load PB data inside there so we need to do it here + CurrentMap.GetMapRecordAndTotals(DB); // Reload the Map record and totals for the HUD + }); + + // This section checks if the PB is better than WR + if(player.Timer.Ticks < CurrentMap.WR[player.Timer.Style].Ticks || CurrentMap.WR[player.Timer.Style].ID == -1) + { + int WrIndex = CurrentMap.ReplayBots.Count-1; // As the ReplaysBot is set, WR Index will always be at the end of the List + AddTimer(2f, () => { + CurrentMap.ReplayBots[WrIndex].Stat_MapTimeID = CurrentMap.WR[player.Timer.Style].ID; + CurrentMap.ReplayBots[WrIndex].LoadReplayData(DB!); + CurrentMap.ReplayBots[WrIndex].ResetReplay(); + }); + } + } } #if DEBUG @@ -48,11 +123,15 @@ internal HookResult OnTriggerStartTouch(DynamicHook handler) } // 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")) + else if (trigger.Entity.Name.Contains("map_start") || + trigger.Entity.Name.Contains("s1_start") || + trigger.Entity.Name.Contains("stage1_start")) { + player.ReplayRecorder.Start(); // Start replay recording + player.Timer.Reset(); + player.Stats.ThisRun.Checkpoint.Clear(); // I have the suspicion that the `Timer.Reset()` does not properly reset this object :thonk: + player.Controller.PrintToCenter($"Map Start ({trigger.Entity.Name})"); #if DEBUG player.Controller.PrintToChat($"CS2 Surf DEBUG >> CBaseTrigger_{ChatColors.Lime}StartTouchFunc{ChatColors.Default} -> {ChatColors.Green}Map Start Zone"); @@ -66,7 +145,38 @@ internal HookResult OnTriggerStartTouch(DynamicHook handler) 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 + Console.WriteLine($"CS2 Surf DEBUG >> CBaseTrigger_StartTouchFunc (Stage start zones) -> player.Timer.IsRunning: {player.Timer.IsRunning}"); + Console.WriteLine($"CS2 Surf DEBUG >> CBaseTrigger_StartTouchFunc (Stage start zones) -> !player.Timer.IsStageMode: {!player.Timer.IsStageMode}"); + Console.WriteLine($"CS2 Surf DEBUG >> CBaseTrigger_StartTouchFunc (Stage start zones) -> player.Stats.ThisRun.Checkpoint.Count <= stage: {player.Stats.ThisRun.Checkpoint.Count <= stage}"); + #endif + + // This should patch up re-triggering *player.Stats.ThisRun.Checkpoint.Count < stage* + if (player.Timer.IsRunning && !player.Timer.IsStageMode && player.Stats.ThisRun.Checkpoint.Count < stage) + { + player.Timer.Checkpoint = stage; // Stage = Checkpoint when in a run on a Staged map + + #if DEBUG + Console.WriteLine($"============== Initial entity value: {Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value} | Assigned to `stage`: {Int32.Parse(Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value) - 1}"); + Console.WriteLine($"CS2 Surf DEBUG >> CBaseTrigger_StartTouchFunc (Stage start zones) -> player.Stats.PB[{style}].Checkpoint.Count = {player.Stats.PB[style].Checkpoint.Count}"); + #endif + + // Print checkpoint message + player.HUD.DisplayCheckpointMessages(PluginPrefix); + + // store the checkpoint in the player's current run checkpoints used for Checkpoint functionality + Checkpoint cp2 = new Checkpoint(stage, + player.Timer.Ticks, + velocity_x, + velocity_y, + velocity_z, + -1.0f, + -1.0f, + -1.0f, + -1.0f, + 0); + player.Stats.ThisRun.Checkpoint[stage] = cp2; + } #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"); @@ -76,7 +186,33 @@ internal HookResult OnTriggerStartTouch(DynamicHook handler) // 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 + int checkpoint = Int32.Parse(Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value); + player.Timer.Checkpoint = checkpoint; + + // This should patch up re-triggering *player.Stats.ThisRun.Checkpoint.Count < checkpoint* + if (player.Timer.IsRunning && !player.Timer.IsStageMode && player.Stats.ThisRun.Checkpoint.Count < checkpoint) + { + #if DEBUG + Console.WriteLine($"============== Initial entity value: {Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value} | Assigned to `checkpoint`: {Int32.Parse(Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value) - 1}"); + Console.WriteLine($"CS2 Surf DEBUG >> CBaseTrigger_StartTouchFunc (Checkpoint zones) -> player.Stats.PB[{style}].Checkpoint.Count = {player.Stats.PB[style].Checkpoint.Count}"); + #endif + + // Print checkpoint message + player.HUD.DisplayCheckpointMessages(PluginPrefix); + + // store the checkpoint in the player's current run checkpoints used for Checkpoint functionality + Checkpoint cp2 = new Checkpoint(checkpoint, + player.Timer.Ticks, + velocity_x, + velocity_y, + velocity_z, + -1.0f, + -1.0f, + -1.0f, + -1.0f, + 0); + player.Stats.ThisRun.Checkpoint[checkpoint] = cp2; + } #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"); diff --git a/src/ST-Map/Map.cs b/src/ST-Map/Map.cs index ac0ed32..c1fb166 100644 --- a/src/ST-Map/Map.cs +++ b/src/ST-Map/Map.cs @@ -6,16 +6,23 @@ namespace SurfTimer; -public class Map +internal class Map { // Map information - public int ID {get; set;} = 0; + public int ID {get; set;} = -1; // Can we use this to re-trigger retrieving map information from the database?? (all db IDs are auto-incremented) public string Name {get; set;} = ""; public string Author {get; set;} = ""; public int Tier {get; set;} = 0; public int Stages {get; set;} = 0; + public int Checkpoints {get; set;} = 0; + public int Bonuses {get; set;} = 0; public bool Ranked {get; set;} = false; public int DateAdded {get; set;} = 0; + public int LastPlayed {get; set;} = 0; + public int TotalCompletions {get; set;} = 0; + public Dictionary WR { get; set; } = new Dictionary(); + public List ConnectedMapTimes { get; set; } = new List(); + public List ReplayBots { get; set; } = new List { new ReplayPlayer() }; // Zone Origin Information // Map start/end zones @@ -29,10 +36,15 @@ public class Map 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 + // Map checkpoint zones + public Vector[] CheckpointStartZone {get;} = Enumerable.Repeat(0, 99).Select(x => new Vector(0,0,0)).ToArray(); // Constructor internal Map(string Name, TimerDatabase DB) { + // Set map name + this.Name = Name; + this.WR[0] = new PersonalBest(); // To-do: Implement styles // Gathering zones from the map IEnumerable triggers = Utilities.FindAllEntitiesByDesignerName("trigger_multiple"); // Gathering info_teleport_destinations from the map @@ -46,14 +58,26 @@ internal Map(string Name, TimerDatabase DB) 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); + bool foundPlayerSpawn = false; // Track whether a player spawn is found foreach (CBaseEntity teleport in teleports) { - if (teleport.Entity!.Name != null && IsInZone(trigger.AbsOrigin!, trigger.Collision.BoundingRadius, teleport.AbsOrigin!)) + if (teleport.Entity!.Name != null && + (IsInZone(trigger.AbsOrigin!, trigger.Collision.BoundingRadius, teleport.AbsOrigin!) || + teleport.Entity!.Name.Contains("spawn_map_start") || + teleport.Entity!.Name.Contains("spawn_stage1_start") || + teleport.Entity!.Name.Contains("spawn_s1_start"))) { + this.StartZone = new Vector(teleport.AbsOrigin!.X, teleport.AbsOrigin!.Y, teleport.AbsOrigin!.Z); this.StartZoneAngles = new QAngle(teleport.AbsRotation!.X, teleport.AbsRotation!.Y, teleport.AbsRotation!.Z); + foundPlayerSpawn = true; + break; } } + + if (!foundPlayerSpawn) + { + this.StartZone = new Vector(trigger.AbsOrigin!.X, trigger.AbsOrigin!.Y, trigger.AbsOrigin!.Z); + } } // Map end zone @@ -65,30 +89,60 @@ internal Map(string Name, TimerDatabase DB) // 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); - + int stage = Int32.Parse(Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value); + // Find an info_destination_teleport inside this zone to grab angles from + bool foundPlayerSpawn = false; // Track whether a player spawn is found foreach (CBaseEntity teleport in teleports) { - if (teleport.Entity!.Name != null && IsInZone(trigger.AbsOrigin!, trigger.Collision.BoundingRadius, teleport.AbsOrigin!)) + if (teleport.Entity!.Name != null && + (IsInZone(trigger.AbsOrigin!, trigger.Collision.BoundingRadius, teleport.AbsOrigin!) || (Regex.Match(teleport.Entity.Name, "^spawn_s([1-9][0-9]?|tage[1-9][0-9]?)_start$").Success && Int32.Parse(Regex.Match(teleport.Entity.Name, "[0-9][0-9]?").Value) == stage))) { - 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); + this.StageStartZone[stage - 1] = new Vector(teleport.AbsOrigin!.X, teleport.AbsOrigin!.Y, teleport.AbsOrigin!.Z); + this.StageStartZoneAngles[stage - 1] = new QAngle(teleport.AbsRotation!.X, teleport.AbsRotation!.Y, teleport.AbsRotation!.Z); + this.Stages++; // Count stage zones for the map to populate DB + foundPlayerSpawn = true; + break; } } + + if (!foundPlayerSpawn) + { + this.StageStartZone[stage - 1] = new Vector(trigger.AbsOrigin!.X, trigger.AbsOrigin!.Y, trigger.AbsOrigin!.Z); + } } + // Checkpoint start zones (linear maps) + else if (Regex.Match(trigger.Entity.Name, "^map_c(p[1-9][0-9]?|heckpoint[1-9][0-9]?)$").Success) + { + this.CheckpointStartZone[Int32.Parse(Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value) - 1] = new Vector(trigger.AbsOrigin!.X, trigger.AbsOrigin!.Y, trigger.AbsOrigin!.Z); + this.Checkpoints++; // Might be useful to have this in DB entry + } + + // Bonus start zones 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); + int bonus = Int32.Parse(Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value); // Find an info_destination_teleport inside this zone to grab angles from + bool foundPlayerSpawn = false; // Track whether a player spawn is found foreach (CBaseEntity teleport in teleports) { - if (teleport.Entity!.Name != null && IsInZone(trigger.AbsOrigin!, trigger.Collision.BoundingRadius, teleport.AbsOrigin!)) + if (teleport.Entity!.Name != null && + (IsInZone(trigger.AbsOrigin!, trigger.Collision.BoundingRadius, teleport.AbsOrigin!) || (Regex.Match(teleport.Entity.Name, "^spawn_b([1-9][0-9]?|onus[1-9][0-9]?)_start$").Success && Int32.Parse(Regex.Match(teleport.Entity.Name, "[0-9][0-9]?").Value) == bonus))) { - 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); + this.BonusStartZone[bonus - 1] = new Vector(teleport.AbsOrigin!.X, teleport.AbsOrigin!.Y, teleport.AbsOrigin!.Z); + this.BonusStartZoneAngles[bonus - 1] = new QAngle(teleport.AbsRotation!.X, teleport.AbsRotation!.Y, teleport.AbsRotation!.Z); + this.Bonuses++; // Count bonus zones for the map to populate DB + foundPlayerSpawn = true; + break; } } + + if (!foundPlayerSpawn) + { + this.BonusStartZone[bonus - 1] = new Vector(trigger.AbsOrigin!.X, trigger.AbsOrigin!.Y, trigger.AbsOrigin!.Z); + } } else if (Regex.Match(trigger.Entity.Name, "^b([1-9][0-9]?|onus[1-9][0-9]?)_end$").Success) @@ -97,54 +151,92 @@ internal Map(string Name, TimerDatabase DB) } } } + if (this.Stages > 0) this.Stages++; // You did not count the stages right :( 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()) + bool updateData = false; + if (mapData.HasRows && mapData.Read()) // In here we can check whether MapData in DB is the same as the newly extracted data, if not, update it (as hookzones may have changed on map updates) { this.ID = mapData.GetInt32("id"); - this.Name = Name; this.Author = mapData.GetString("author") ?? "Unknown"; this.Tier = mapData.GetInt32("tier"); - this.Stages = mapData.GetInt32("stages"); + if (this.Stages != mapData.GetInt32("stages") || this.Bonuses != mapData.GetInt32("bonuses")) + updateData = true; + // this.Stages = mapData.GetInt32("stages"); // this should now be populated accordingly when looping through hookzones for the map + // this.Bonuses = mapData.GetInt32("bonuses"); // this should now be populated accordingly when looping through hookzones for the map this.Ranked = mapData.GetBoolean("ranked"); this.DateAdded = mapData.GetInt32("date_added"); + this.LastPlayed = mapData.GetInt32("last_played"); + updateData = true; 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()})"); + Task writer = DB.Write($"INSERT INTO Maps (name, author, tier, stages, ranked, date_added, last_played) VALUES ('{MySqlHelper.EscapeString(Name)}', 'Unknown', {this.Stages}, {this.Bonuses}, 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}"); - + throw new Exception($"CS2 Surf ERROR >> OnRoundStart -> new Map() -> Failed to write new map to database, this shouldn't happen. Map: {Name}"); + writer.Dispose(); + 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"); + this.Author = postWriteMapData.GetString("author"); + this.Tier = postWriteMapData.GetInt32("tier"); + // this.Stages = -1; // this should now be populated accordingly when looping through hookzones for the map + // this.Bonuses = -1; // this should now be populated accordingly when looping through hookzones for the map + this.Ranked = postWriteMapData.GetBoolean("ranked"); + this.DateAdded = postWriteMapData.GetInt32("date_added"); + this.LastPlayed = this.DateAdded; } 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}"); + // Update last_played data or update last_played, stages, and bonuses data + string query = $"UPDATE Maps SET last_played={(int)DateTimeOffset.UtcNow.ToUnixTimeSeconds()} WHERE id={this.ID}"; + if (updateData) query = $"UPDATE Maps SET last_played={(int)DateTimeOffset.UtcNow.ToUnixTimeSeconds()}, stages={this.Stages}, bonuses={this.Bonuses} WHERE id={this.ID}"; + #if DEBUG + Console.WriteLine($"CS2 Surf ERROR >> OnRoundStart -> update Map() -> Update MapData: {query}"); + #endif + + Task updater = DB.Write(query); 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}"); + throw new Exception($"CS2 Surf ERROR >> OnRoundStart -> update Map() -> Failed to update map in database, this shouldnt happen. Map: {Name} | was it 'big' update? {updateData}"); + updater.Dispose(); + + // Initiates getting the World Records for the map + GetMapRecordAndTotals(DB); // To-do: Implement styles + + this.ReplayBots[0].Stat_MapTimeID = this.WR[0].ID; // Sets WrIndex to WR maptime_id + if(this.Stages > 0) // If stages map adds bot + this.ReplayBots = this.ReplayBots.Prepend(new ReplayPlayer()).ToList(); + + if(this.Bonuses > 0) // If has bonuses adds bot + this.ReplayBots = this.ReplayBots.Prepend(new ReplayPlayer()).ToList(); + } + + public void KickReplayBot(int index) + { + if (!this.ReplayBots[index].IsPlayable) + return; + + int? id_to_kick = this.ReplayBots[index].Controller!.UserId; + if(id_to_kick == null) + return; + + this.ReplayBots.RemoveAt(index); + Server.ExecuteCommand($"kickid {id_to_kick}; bot_quota {this.ReplayBots.Count}"); } public bool IsInZone(Vector zoneOrigin, float zoneCollisionRadius, Vector spawnOrigin) @@ -156,4 +248,82 @@ public bool IsInZone(Vector zoneOrigin, float zoneCollisionRadius, Vector spawnO else return false; } + + // Leaving this outside of the constructor for `Map` so we can call it to ONLY update the data when a new world record is set + internal void GetMapRecordAndTotals(TimerDatabase DB, int style = 0 ) // To-do: Implement styles + { + // Get map world records + Task reader = DB.Query($@" + SELECT MapTimes.*, Player.name + FROM MapTimes + JOIN Player ON MapTimes.player_id = Player.id + WHERE MapTimes.map_id = {this.ID} AND MapTimes.style = {style} + ORDER BY MapTimes.run_time ASC; + "); + MySqlDataReader mapWrData = reader.Result; + int totalRows = 0; + + if (mapWrData.HasRows) + { + // To-do: Implement bonuses WR + // To-do: Implement stages WR + this.ConnectedMapTimes.Clear(); + while (mapWrData.Read()) + { + if (totalRows == 0) // We are sorting by `run_time ASC` so the first row is always the fastest run for the map and style combo :) + { + this.WR[style].ID = mapWrData.GetInt32("id"); // WR ID for the Map and Style combo + this.WR[style].Ticks = mapWrData.GetInt32("run_time"); // Fastest run time (WR) for the Map and Style combo + this.WR[style].StartVelX = mapWrData.GetFloat("start_vel_x"); // Fastest run start velocity X for the Map and Style combo + this.WR[style].StartVelY = mapWrData.GetFloat("start_vel_y"); // Fastest run start velocity Y for the Map and Style combo + this.WR[style].StartVelZ = mapWrData.GetFloat("start_vel_z"); // Fastest run start velocity Z for the Map and Style combo + this.WR[style].EndVelX = mapWrData.GetFloat("end_vel_x"); // Fastest run end velocity X for the Map and Style combo + this.WR[style].EndVelY = mapWrData.GetFloat("end_vel_y"); // Fastest run end velocity Y for the Map and Style combo + this.WR[style].EndVelZ = mapWrData.GetFloat("end_vel_z"); // Fastest run end velocity Z for the Map and Style combo + this.WR[style].RunDate = mapWrData.GetInt32("run_date"); // Fastest run date for the Map and Style combo + this.WR[style].Name = mapWrData.GetString("name"); // Fastest run player name for the Map and Style combo + } + this.ConnectedMapTimes.Add(mapWrData.GetInt32("id")); + totalRows++; + } + } + mapWrData.Close(); + this.TotalCompletions = totalRows; // Total completions for the map and style - this should maybe be added to PersonalBest class + + // Get map world record checkpoints + if (totalRows != 0) + { + Task cpReader = DB.Query($"SELECT * FROM `Checkpoints` WHERE `maptime_id` = {this.WR[style].ID};"); + MySqlDataReader cpWrData = cpReader.Result; + while (cpWrData.Read()) + { + #if DEBUG + Console.WriteLine($"cp {cpWrData.GetInt32("cp")} "); + Console.WriteLine($"run_time {cpWrData.GetFloat("run_time")} "); + Console.WriteLine($"sVelX {cpWrData.GetFloat("start_vel_x")} "); + Console.WriteLine($"sVelY {cpWrData.GetFloat("start_vel_y")} "); + #endif + + Checkpoint cp = new(cpWrData.GetInt32("cp"), + cpWrData.GetInt32("run_time"), // To-do: what type of value we use here? DB uses DECIMAL but `.Tick` is int??? + cpWrData.GetFloat("start_vel_x"), + cpWrData.GetFloat("start_vel_y"), + cpWrData.GetFloat("start_vel_z"), + cpWrData.GetFloat("end_vel_x"), + cpWrData.GetFloat("end_vel_y"), + cpWrData.GetFloat("end_vel_z"), + cpWrData.GetFloat("end_touch"), + cpWrData.GetInt32("attempts")); + cp.ID = cpWrData.GetInt32("cp"); + // To-do: cp.ID = calculate Rank # from DB + + this.WR[style].Checkpoint[cp.CP] = cp; + + #if DEBUG + Console.WriteLine($"======= CS2 Surf DEBUG >> internal void GetMapRecordAndTotals : Map -> Loaded WR CP {cp.CP} with RunTime {cp.Ticks} for MapTimeID {WR[0].ID} (MapId = {this.ID})."); + #endif + } + cpWrData.Close(); + } + } } \ No newline at end of file diff --git a/src/ST-Player/Player.cs b/src/ST-Player/Player.cs index 3243b29..2bcfb83 100644 --- a/src/ST-Player/Player.cs +++ b/src/ST-Player/Player.cs @@ -11,12 +11,18 @@ internal class Player public PlayerTimer Timer {get; set;} public PlayerStats Stats {get; set;} public PlayerHUD HUD {get; set;} + public ReplayRecorder ReplayRecorder { get; set; } + public List SavedLocations { get; set; } + public int CurrentSavedLocation { get; set; } // Player information public PlayerProfile Profile {get; set;} + // Map information + public Map CurrMap = null!; + // Constructor - public Player(CCSPlayerController Controller, CCSPlayer_MovementServices MovementServices, PlayerProfile Profile) + public Player(CCSPlayerController Controller, CCSPlayer_MovementServices MovementServices, PlayerProfile Profile, Map CurrMap) { this.Controller = Controller; this.MovementServices = MovementServices; @@ -25,7 +31,22 @@ public Player(CCSPlayerController Controller, CCSPlayer_MovementServices Movemen this.Timer = new PlayerTimer(); this.Stats = new PlayerStats(); + this.ReplayRecorder = new ReplayRecorder(); + this.SavedLocations = new List(); + CurrentSavedLocation = 0; this.HUD = new PlayerHUD(this); + this.CurrMap = CurrMap; + } + + /// + /// Checks if current player is spcetating player

+ ///

+ public bool IsSpectating(CCSPlayerController p) + { + if(p == null || this.Controller == null || this.Controller.Team != CounterStrikeSharp.API.Modules.Utils.CsTeam.Spectator) + return false; + + return p.Pawn.SerialNum == this.Controller.ObserverPawn.Value!.ObserverServices!.ObserverTarget.SerialNum; } } diff --git a/src/ST-Player/PlayerHUD.cs b/src/ST-Player/PlayerHUD.cs index a92978c..4ed6c2b 100644 --- a/src/ST-Player/PlayerHUD.cs +++ b/src/ST-Player/PlayerHUD.cs @@ -1,6 +1,8 @@ +using CounterStrikeSharp.API.Modules.Utils; + namespace SurfTimer; -internal class PlayerHUD +internal class PlayerHUD { private Player _player; @@ -28,22 +30,45 @@ private string FormatHUDElementHTML(string title, string body, string color, str } } - public string FormatTime(int ticks) // https://github.com/DEAFPS/SharpTimer/blob/e4ef24fff29a33c36722d23961355742d507441f/Utils.cs#L38 + /// + /// Formats the given time in ticks into a readable time string. + /// Unless specified differently, the default formatting will be `Compact`. + /// Check for all formatting types. + /// + public static string FormatTime(int ticks, PlayerTimer.TimeFormatStyle style = PlayerTimer.TimeFormatStyle.Compact) { 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}"; + + switch (style) + { + case PlayerTimer.TimeFormatStyle.Compact: + return time.TotalMinutes < 1 + ? $"{time.Seconds:D1}:{millis:D3}" + : $"{time.Minutes:D1}:{time.Seconds:D1}.{millis:D3}"; + case PlayerTimer.TimeFormatStyle.Full: + return $"{time.Hours:D2}:{time.Minutes:D2}:{time.Seconds:D2}.{millis:D3}"; + case PlayerTimer.TimeFormatStyle.Verbose: + return $"{time.Hours}h {time.Minutes}m {time.Seconds}s {millis}ms"; + default: + throw new ArgumentException("Invalid time format style"); + } } public void Display() { - if (_player.Controller.IsValid && _player.Controller.PawnIsAlive) + if(!_player.Controller.IsValid) + return; + + if (_player.Controller.PawnIsAlive) { + int style = _player.Timer.Style; // Timer Module string timerColor = "#79d1ed"; + if (_player.Timer.IsRunning) { - if (_player.Timer.PracticeMode) + if (_player.Timer.IsPracticeMode) timerColor = "#F2C94C"; else timerColor = "#2E9F65"; @@ -51,15 +76,23 @@ public void Display() 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 + 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"; + string velocityModule = FormatHUDElementHTML("Speed", velocity.ToString("0"), "#79d1ed") + " u/s"; // Rank Module - string rankModule = FormatHUDElementHTML("Rank", "N/A", "#7882dd"); // IMPLEMENT IN PlayerStats + string rankModule = FormatHUDElementHTML("Rank", $"N/A", "#7882dd"); + if (_player.Stats.PB[style].ID != -1 && _player.CurrMap.WR[style].ID != -1) + { + rankModule = FormatHUDElementHTML("Rank", $"{_player.Stats.PB[style].Rank}/{_player.CurrMap.TotalCompletions}", "#7882dd"); + } + else if (_player.CurrMap.WR[style].ID != -1) + { + rankModule = FormatHUDElementHTML("Rank", $"-/{_player.CurrMap.TotalCompletions}", "#7882dd"); + } // 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 + string pbModule = FormatHUDElementHTML("PB", _player.Stats.PB[style].Ticks > 0 ? FormatTime(_player.Stats.PB[style].Ticks) : "N/A", "#7882dd"); // IMPLEMENT IN PlayerStats // To-do: make Style (currently 0) be dynamic + string wrModule = FormatHUDElementHTML("WR", _player.CurrMap.WR[style].Ticks > 0 ? FormatTime(_player.CurrMap.WR[style].Ticks) : "N/A", "#ffc61a"); // IMPLEMENT IN PlayerStats - This should be part of CurrentMap, not PlayerStats? // Build HUD string hud = $"{timerModule}
{velocityModule}
{pbModule} | {rankModule}
{wrModule}"; @@ -67,5 +100,159 @@ public void Display() // Display HUD _player.Controller.PrintToCenterHtml(hud); } + else if (_player.Controller.Team == CsTeam.Spectator) + { + for (int i = 0; i < _player.CurrMap.ReplayBots.Count; i++) + { + if(!_player.CurrMap.ReplayBots[i].IsPlayable || !_player.IsSpectating(_player.CurrMap.ReplayBots[i].Controller!)) + continue; + + string replayModule = $"{FormatHUDElementHTML("", "REPLAY", "red", "large")}"; + + string nameModule = FormatHUDElementHTML($"{_player.CurrMap.ReplayBots[i].Stat_PlayerName}", $"{FormatTime(_player.CurrMap.ReplayBots[i].Stat_RunTime)}", "#ffd500"); + + string elapsed_time = FormatHUDElementHTML("Time", $"{PlayerHUD.FormatTime(_player.CurrMap.ReplayBots[i].Stat_RunTick)}", "#7882dd"); + string hud = $"{replayModule}
{elapsed_time}
{nameModule}"; + + _player.Controller.PrintToCenterHtml(hud); + } + } + } + + /// + /// Only calculates if the player has a PB, otherwise it will display N/A + /// + /// + public void DisplayCheckpointMessages(string PluginPrefix) // To-do: PluginPrefix should be accessible in here without passing it as a parameter + { + int pbTime; + int wrTime = -1; + float pbSpeed; + float wrSpeed = -1.0f; + int style = _player.Timer.Style; + + int currentTime = _player.Timer.Ticks; + float currentSpeed = (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); + + // Default values for the PB and WR differences in case no calculations can be made + string strPbDifference = $"{ChatColors.Grey}N/A{ChatColors.Default} ({ChatColors.Grey}N/A{ChatColors.Default})"; + string strWrDifference = $"{ChatColors.Grey}N/A{ChatColors.Default} ({ChatColors.Grey}N/A{ChatColors.Default})"; + + // We need to try/catch this because the player might not have a PB for this stage in this case but they will not have for the map as well + // Can check checkpoints count instead of try/catch + try + { + pbTime = _player.Stats.PB[style].Checkpoint[_player.Timer.Checkpoint].Ticks; + pbSpeed = (float)Math.Sqrt(_player.Stats.PB[style].Checkpoint[_player.Timer.Checkpoint].StartVelX * _player.Stats.PB[style].Checkpoint[_player.Timer.Checkpoint].StartVelX + + _player.Stats.PB[style].Checkpoint[_player.Timer.Checkpoint].StartVelY * _player.Stats.PB[style].Checkpoint[_player.Timer.Checkpoint].StartVelY + + _player.Stats.PB[style].Checkpoint[_player.Timer.Checkpoint].StartVelZ * _player.Stats.PB[style].Checkpoint[_player.Timer.Checkpoint].StartVelZ); + + #if DEBUG + Console.WriteLine($"CS2 Surf DEBUG >> DisplayCheckpointMessages -> [TIME] Got pbTime from _player.Stats.PB[{style}].Checkpoint[{_player.Timer.Checkpoint} = {pbTime}]"); + Console.WriteLine($"CS2 Surf DEBUG >> DisplayCheckpointMessages -> [SPEED] Got pbSpeed from _player.Stats.PB[{style}].Checkpoint[{_player.Timer.Checkpoint}] = {pbSpeed}"); + #endif + } + #if DEBUG + catch (System.Exception ex) + #else + catch (System.Exception) + #endif + { + // Handle the exception gracefully without stopping the application + // We assign default values to pbTime and pbSpeed + pbTime = -1; // This determines if we will calculate differences or not!!! + pbSpeed = 0.0f; + + #if DEBUG + Console.WriteLine($"CS2 Surf CAUGHT EXCEPTION >> DisplayCheckpointMessages -> An error occurred: {ex.Message}"); + Console.WriteLine($"CS2 Surf CAUGHT EXCEPTION >> DisplayCheckpointMessages -> An error occurred Player has no PB and therefore no Checkpoints | _player.Stats.PB[{style}].Checkpoint.Count = {_player.Stats.PB[style].Checkpoint.Count}"); + #endif + } + + // Calculate differences in PB (PB - Current) + if (pbTime != -1) + { + #if DEBUG + Console.WriteLine($"CS2 Surf DEBUG >> DisplayCheckpointMessages -> Starting PB difference calculation... (pbTime != -1)"); + #endif + // Reset the string + strPbDifference = ""; + + // Calculate the time difference + if (pbTime - currentTime < 0.0) + { + strPbDifference += ChatColors.Red + "+" + FormatTime((pbTime - currentTime) * -1); // We multiply by -1 to get the positive value + } + else if (pbTime - currentTime >= 0.0) + { + strPbDifference += ChatColors.Green + "-" + FormatTime(pbTime - currentTime); + } + strPbDifference += ChatColors.Default + " "; + + // Calculate the speed difference + if (pbSpeed - currentSpeed <= 0.0) + { + strPbDifference += "(" + ChatColors.Green + "+" + ((pbSpeed - currentSpeed) * -1).ToString("0"); // We multiply by -1 to get the positive value + } + else if (pbSpeed - currentSpeed > 0.0) + { + strPbDifference += "(" + ChatColors.Red + "-" + (pbSpeed - currentSpeed).ToString("0"); + } + strPbDifference += ChatColors.Default + ")"; + } + + if (_player.CurrMap.WR[style].Ticks > 0) + { + // Calculate differences in WR (WR - Current) + #if DEBUG + Console.WriteLine($"CS2 Surf DEBUG >> DisplayCheckpointMessages -> Starting WR difference calculation... (_player.CurrMap.WR[{style}].Ticks > 0)"); + #endif + + wrTime = _player.CurrMap.WR[style].Checkpoint[_player.Timer.Checkpoint].Ticks; + wrSpeed = (float)Math.Sqrt(_player.CurrMap.WR[style].Checkpoint[_player.Timer.Checkpoint].StartVelX * _player.CurrMap.WR[style].Checkpoint[_player.Timer.Checkpoint].StartVelX + + _player.CurrMap.WR[style].Checkpoint[_player.Timer.Checkpoint].StartVelY * _player.CurrMap.WR[style].Checkpoint[_player.Timer.Checkpoint].StartVelY + + _player.CurrMap.WR[style].Checkpoint[_player.Timer.Checkpoint].StartVelZ * _player.CurrMap.WR[style].Checkpoint[_player.Timer.Checkpoint].StartVelZ); + // Reset the string + strWrDifference = ""; + + // Calculate the WR time difference + if (wrTime - currentTime < 0.0) + { + strWrDifference += ChatColors.Red + "+" + FormatTime((wrTime - currentTime) * -1); // We multiply by -1 to get the positive value + } + else if (wrTime - currentTime >= 0.0) + { + strWrDifference += ChatColors.Green + "-" + FormatTime(wrTime - currentTime); + } + strWrDifference += ChatColors.Default + " "; + + // Calculate the WR speed difference + if (wrSpeed - currentSpeed <= 0.0) + { + strWrDifference += "(" + ChatColors.Green + "+" + ((wrSpeed - currentSpeed) * -1).ToString("0"); // We multiply by -1 to get the positive value + } + else if (wrSpeed - currentSpeed > 0.0) + { + strWrDifference += "(" + ChatColors.Red + "-" + (wrSpeed - currentSpeed).ToString("0"); + } + strWrDifference += ChatColors.Default + ")"; + } + + // Print checkpoint message + _player.Controller.PrintToChat( + $"{PluginPrefix} CP [{ChatColors.Yellow}{_player.Timer.Checkpoint}{ChatColors.Default}]: " + + $"{ChatColors.Yellow}{FormatTime(_player.Timer.Ticks)}{ChatColors.Default} " + + $"{ChatColors.Yellow}({currentSpeed.ToString("0")}){ChatColors.Default} " + + $"[PB: {strPbDifference} | " + + $"WR: {strWrDifference}]"); + + #if DEBUG + Console.WriteLine($"CS2 Surf DEBUG >> DisplayCheckpointMessages -> [TIME] PB: {pbTime} - CURR: {currentTime} = pbTime: {pbTime - currentTime}"); + Console.WriteLine($"CS2 Surf DEBUG >> DisplayCheckpointMessages -> [SPEED] PB: {pbSpeed} - CURR: {currentSpeed} = difference: {pbSpeed - currentSpeed}"); + Console.WriteLine($"CS2 Surf DEBUG >> DisplayCheckpointMessages -> [TIME] WR: {wrTime} - CURR: {currentTime} = difference: {wrTime - currentTime}"); + Console.WriteLine($"CS2 Surf DEBUG >> DisplayCheckpointMessages -> [SPEED] WR: {wrSpeed} - CURR: {currentSpeed} = difference: {wrSpeed - currentSpeed}"); + #endif } } diff --git a/src/ST-Player/PlayerStats.cs b/src/ST-Player/PlayerStats.cs deleted file mode 100644 index 4f9c005..0000000 --- a/src/ST-Player/PlayerStats.cs +++ /dev/null @@ -1,14 +0,0 @@ -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/PlayerStats/Checkpoint.cs b/src/ST-Player/PlayerStats/Checkpoint.cs new file mode 100644 index 0000000..bf51165 --- /dev/null +++ b/src/ST-Player/PlayerStats/Checkpoint.cs @@ -0,0 +1,22 @@ +namespace SurfTimer; + +internal class Checkpoint : PersonalBest +{ + public int CP { get; set; } + public float EndTouch { get; set; } + public int Attempts { get; set; } + + public Checkpoint(int cp, int ticks, float startVelX, float startVelY, float startVelZ, float endVelX, float endVelY, float endVelZ, float endTouch, int attempts) + { + CP = cp; + Ticks = ticks; // To-do: this was supposed to be the ticks but that is used for run_time for HUD???? + StartVelX = startVelX; + StartVelY = startVelY; + StartVelZ = startVelZ; + EndVelX = endVelX; + EndVelY = endVelY; + EndVelZ = endVelZ; + EndTouch = endTouch; + Attempts = attempts; + } +} \ No newline at end of file diff --git a/src/ST-Player/PlayerStats/CurrentRun.cs b/src/ST-Player/PlayerStats/CurrentRun.cs new file mode 100644 index 0000000..4609dd1 --- /dev/null +++ b/src/ST-Player/PlayerStats/CurrentRun.cs @@ -0,0 +1,130 @@ +namespace SurfTimer; + +/// +/// This class stores data for the current run. +/// +internal class CurrentRun +{ + public Dictionary Checkpoint { get; set; } // Current RUN checkpoints tracker + public int Ticks { get; set; } // To-do: will be the last (any) zone end touch time + public float StartVelX { get; set; } // This will store MAP START VELOCITY X + public float StartVelY { get; set; } // This will store MAP START VELOCITY Y + public float StartVelZ { get; set; } // This will store MAP START VELOCITY Z + public float EndVelX { get; set; } // This will store MAP END VELOCITY X + public float EndVelY { get; set; } // This will store MAP END VELOCITY Y + public float EndVelZ { get; set; } // This will store MAP END VELOCITY Z + public int RunDate { get; set; } + // Add other properties as needed + + // Constructor + public CurrentRun() + { + Checkpoint = new Dictionary(); + Ticks = 0; + StartVelX = 0.0f; + StartVelY = 0.0f; + StartVelZ = 0.0f; + EndVelX = 0.0f; + EndVelY = 0.0f; + EndVelZ = 0.0f; + RunDate = 0; + } + + public void Reset() + { + Checkpoint.Clear(); + Ticks = 0; + StartVelX = 0.0f; + StartVelY = 0.0f; + StartVelZ = 0.0f; + EndVelX = 0.0f; + EndVelY = 0.0f; + EndVelZ = 0.0f; + RunDate = 0; + // Reset other properties as needed + } + + /// + /// Saves the player's run to the database and reloads the data for the player. + /// NOTE: Not re-loading any data at this point as we need `LoadMapTimesData` to be called from here as well, otherwise we may not have the `this.ID` populated + /// + public void SaveMapTime(Player player, TimerDatabase DB) + { + // Add entry in DB for the run + // To-do: add `type` + int style = player.Timer.Style; + string replay_frames = player.ReplayRecorder.SerializeReplay(); + Task updatePlayerRunTask = DB.Write($@" + INSERT INTO `MapTimes` + (`player_id`, `map_id`, `style`, `type`, `stage`, `run_time`, `start_vel_x`, `start_vel_y`, `start_vel_z`, `end_vel_x`, `end_vel_y`, `end_vel_z`, `run_date`, `replay_frames`) + VALUES ({player.Profile.ID}, {player.CurrMap.ID}, {style}, 0, 0, {player.Stats.ThisRun.Ticks}, + {player.Stats.ThisRun.StartVelX}, {player.Stats.ThisRun.StartVelY}, {player.Stats.ThisRun.StartVelZ}, {player.Stats.ThisRun.EndVelX}, {player.Stats.ThisRun.EndVelY}, {player.Stats.ThisRun.EndVelZ}, {(int)DateTimeOffset.UtcNow.ToUnixTimeSeconds()}, '{replay_frames}') + ON DUPLICATE KEY UPDATE run_time=VALUES(run_time), start_vel_x=VALUES(start_vel_x), start_vel_y=VALUES(start_vel_y), + start_vel_z=VALUES(start_vel_z), end_vel_x=VALUES(end_vel_x), end_vel_y=VALUES(end_vel_y), end_vel_z=VALUES(end_vel_z), run_date=VALUES(run_date), replay_frames=VALUES(replay_frames); + "); + if (updatePlayerRunTask.Result <= 0) + throw new Exception($"CS2 Surf ERROR >> internal class PersonalBest -> SaveMapTime -> Failed to insert/update player run in database. Player: {player.Profile.Name} ({player.Profile.SteamID})"); + updatePlayerRunTask.Dispose(); + + // Will have to LoadMapTimesData right here as well to get the ID of the run we just inserted + // this.SaveCurrentRunCheckpoints(player, DB); // Save checkpoints for this run + // this.LoadCheckpointsForRun(DB); // Re-Load checkpoints for this run + } + + /// + /// Saves the `CurrentRunCheckpoints` dictionary to the database + /// We need the correct `this.ID` to be populated before calling this method otherwise Query will fail + /// + public void SaveCurrentRunCheckpoints(Player player, TimerDatabase DB) // To-do: Transactions? Player sometimes rubberbands for a bit here + { + int style = player.Timer.Style; + // Loop through the checkpoints and insert/update them in the database for the run + foreach (var item in player.Stats.ThisRun.Checkpoint) + { + int cp = item.Key; + int ticks = item.Value!.Ticks; + int runTime = item.Value!.Ticks / 64; // Runtime in decimal + double startVelX = item.Value!.StartVelX; + double startVelY = item.Value!.StartVelY; + double startVelZ = item.Value!.StartVelZ; + double endVelX = item.Value!.EndVelX; + double endVelY = item.Value!.EndVelY; + double endVelZ = item.Value!.EndVelZ; + int attempts = item.Value!.Attempts; + + #if DEBUG + Console.WriteLine($"CP: {cp} | MapTime ID: {player.Stats.PB[style].ID} | Time: {runTime} | Ticks: {ticks} | startVelX: {startVelX} | startVelY: {startVelY} | startVelZ: {startVelZ} | endVelX: {endVelX} | endVelY: {endVelY} | endVelZ: {endVelZ}"); + Console.WriteLine($@"CS2 Surf DEBUG >> internal class Checkpoint : PersonalBest -> SaveCurrentRunCheckpoints -> + INSERT INTO `Checkpoints` + (`maptime_id`, `cp`, `run_time`, `start_vel_x`, `start_vel_y`, `start_vel_z`, + `end_vel_x`, `end_vel_y`, `end_vel_z`, `attempts`, `end_touch`) + VALUES ({player.Stats.PB[style].ID}, {cp}, {runTime}, {startVelX}, {startVelY}, {startVelZ}, {endVelX}, {endVelY}, {endVelZ}, {attempts}, {ticks}) ON DUPLICATE KEY UPDATE + run_time=VALUES(run_time), start_vel_x=VALUES(start_vel_x), start_vel_y=VALUES(start_vel_y), start_vel_z=VALUES(start_vel_z), + end_vel_x=VALUES(end_vel_x), end_vel_y=VALUES(end_vel_y), end_vel_z=VALUES(end_vel_z), attempts=VALUES(attempts), end_touch=VALUES(end_touch); + "); + #endif + + // Insert/Update CPs to database + // To-do: Transactions? + // Check if the player has PB object initialized and if the player's character is currently active in the game + if (item.Value != null && player.Controller.PlayerPawn.Value != null) + { + Task newPbTask = DB.Write($@" + INSERT INTO `Checkpoints` + (`maptime_id`, `cp`, `run_time`, `start_vel_x`, `start_vel_y`, `start_vel_z`, + `end_vel_x`, `end_vel_y`, `end_vel_z`, `attempts`, `end_touch`) + VALUES ({player.Stats.PB[style].ID}, {cp}, {runTime}, {startVelX}, {startVelY}, {startVelZ}, {endVelX}, {endVelY}, {endVelZ}, {attempts}, {ticks}) + ON DUPLICATE KEY UPDATE + run_time=VALUES(run_time), start_vel_x=VALUES(start_vel_x), start_vel_y=VALUES(start_vel_y), start_vel_z=VALUES(start_vel_z), + end_vel_x=VALUES(end_vel_x), end_vel_y=VALUES(end_vel_y), end_vel_z=VALUES(end_vel_z), attempts=VALUES(attempts), end_touch=VALUES(end_touch); + "); + if (newPbTask.Result <= 0) + throw new Exception($"CS2 Surf ERROR >> internal class Checkpoint : PersonalBest -> SaveCurrentRunCheckpoints -> Inserting Checkpoints. CP: {cp} | Name: {player.Profile.Name}"); + + newPbTask.Dispose(); + } + } + + player.Stats.ThisRun.Checkpoint.Clear(); + } +} diff --git a/src/ST-Player/PlayerStats/PersonalBest.cs b/src/ST-Player/PlayerStats/PersonalBest.cs new file mode 100644 index 0000000..2ee50bc --- /dev/null +++ b/src/ST-Player/PlayerStats/PersonalBest.cs @@ -0,0 +1,36 @@ +namespace SurfTimer; + +// To-do: make Style (currently 0) be dynamic +// To-do: add `Type` +internal class PersonalBest +{ + public int ID { get; set; } = -1; // Exclude from constructor, retrieve from Database when loading/saving + public int Ticks { get; set; } + public int Rank { get; set; } = -1; // Exclude from constructor, retrieve from Database when loading/saving + public Dictionary Checkpoint { get; set; } + // public int Type { get; set; } + public float StartVelX { get; set; } + public float StartVelY { get; set; } + public float StartVelZ { get; set; } + public float EndVelX { get; set; } + public float EndVelY { get; set; } + public float EndVelZ { get; set; } + public int RunDate { get; set; } + public string Name { get; set; } = ""; // This is used only for WRs + // Add other properties as needed + + // Constructor + public PersonalBest() + { + Ticks = -1; // To-do: what type of value we use here? DB uses DECIMAL but `.Tick` is int??? + Checkpoint = new Dictionary(); + // Type = type; + StartVelX = -1.0f; + StartVelY = -1.0f; + StartVelZ = -1.0f; + EndVelX = -1.0f; + EndVelY = -1.0f; + EndVelZ = -1.0f; + RunDate = 0; + } +} \ No newline at end of file diff --git a/src/ST-Player/PlayerStats/PlayerStats.cs b/src/ST-Player/PlayerStats/PlayerStats.cs new file mode 100644 index 0000000..48eed71 --- /dev/null +++ b/src/ST-Player/PlayerStats/PlayerStats.cs @@ -0,0 +1,150 @@ +using MySqlConnector; + +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[,] 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 + // + + public Dictionary PB { get; set; } = new Dictionary(); + public CurrentRun ThisRun { get; set; } = new CurrentRun(); // This is a CurrenntRun object that tracks the data for the Player's current run + // Initialize PersonalBest for each `style` (e.g., 0 for normal) - this is a temporary solution + // Here we can loop through all available styles at some point and initialize them + public PlayerStats() + { + PB[0] = new PersonalBest(); + // Add more styles as needed + } + + /// + /// Loads the player's MapTimes data from the database along with `Rank` for the run. + /// `Checkpoints` are loaded separately because inside the while loop we cannot run queries. + /// This can populate all the `style` stats the player has for the map - currently only 1 style is supported + /// + public void LoadMapTimesData(Player player, TimerDatabase DB, int playerId = 0, int mapId = 0) + { + Task dbTask2 = DB.Query($@" + SELECT mainquery.*, (SELECT COUNT(*) FROM `MapTimes` AS subquery + WHERE subquery.`map_id` = mainquery.`map_id` AND subquery.`style` = mainquery.`style` + AND subquery.`run_time` <= mainquery.`run_time`) AS `rank` FROM `MapTimes` AS mainquery + WHERE mainquery.`player_id` = {player.Profile.ID} AND mainquery.`map_id` = {player.CurrMap.ID}; + "); + MySqlDataReader playerStats = dbTask2.Result; + int style = 0; // To-do: implement styles + if (!playerStats.HasRows) + { + Console.WriteLine($"CS2 Surf DEBUG >> internal class PlayerStats -> LoadMapTimesData -> No MapTimes data found for Player."); + } + else + { + while (playerStats.Read()) + { + // Load data into PersonalBest object + // style = playerStats.GetInt32("style"); // Uncomment when style is implemented + PB[style].ID = playerStats.GetInt32("id"); + PB[style].StartVelX = (float)playerStats.GetDouble("start_vel_x"); + PB[style].StartVelY = (float)playerStats.GetDouble("start_vel_y"); + PB[style].StartVelZ = (float)playerStats.GetDouble("start_vel_z"); + PB[style].EndVelX = (float)playerStats.GetDouble("end_vel_x"); + PB[style].EndVelY = (float)playerStats.GetDouble("end_vel_y"); + PB[style].EndVelZ = (float)playerStats.GetDouble("end_vel_z"); + PB[style].Ticks = playerStats.GetInt32("run_time"); + PB[style].RunDate = playerStats.GetInt32("run_date"); + PB[style].Rank = playerStats.GetInt32("rank"); + + Console.WriteLine($"============== CS2 Surf DEBUG >> LoadMapTimesData -> PlayerID: {player.Profile.ID} | Rank: {PB[style].Rank} | ID: {PB[style].ID} | RunTime: {PB[style].Ticks} | SVX: {PB[style].StartVelX} | SVY: {PB[style].StartVelY} | SVZ: {PB[style].StartVelZ} | EVX: {PB[style].EndVelX} | EVY: {PB[style].EndVelY} | EVZ: {PB[style].EndVelZ} | Run Date (UNIX): {PB[style].RunDate}"); + #if DEBUG + Console.WriteLine($"CS2 Surf DEBUG >> internal class PlayerStats -> LoadMapTimesData -> PlayerStats.PB (ID {PB[style].ID}) loaded from DB."); + #endif + } + } + playerStats.Close(); + } + + /// + /// Executes the DB query to get all the checkpoints and store them in the Checkpoint dictionary + /// + public void LoadCheckpointsData(TimerDatabase DB) + { + Task dbTask = DB.Query($"SELECT * FROM `Checkpoints` WHERE `maptime_id` = {PB[0].ID};"); + MySqlDataReader results = dbTask.Result; + if (PB[0] == null) + { + #if DEBUG + Console.WriteLine("CS2 Surf ERROR >> internal class PlayerStats -> LoadCheckpointsData -> PersonalBest object is null."); + #endif + + results.Close(); + return; + } + + if (PB[0].Checkpoint == null) + { + #if DEBUG + Console.WriteLine($"CS2 Surf DEBUG >> internal class PlayerStats -> LoadCheckpointsData -> PB Checkpoints list is not initialized."); + #endif + + PB[0].Checkpoint = new Dictionary(); // Initialize if null + } + + #if DEBUG + Console.WriteLine($"this.Checkpoint.Count {PB[0].Checkpoint.Count} "); + Console.WriteLine($"this.ID {PB[0].ID} "); + Console.WriteLine($"this.Ticks {PB[0].Ticks} "); + Console.WriteLine($"this.RunDate {PB[0].RunDate} "); + #endif + + if (!results.HasRows) + { + #if DEBUG + Console.WriteLine($"CS2 Surf DEBUG >> internal class Checkpoint : PersonalBest -> LoadCheckpointsData -> No checkpoints found for this mapTimeId {PB[0].ID}."); + #endif + + results.Close(); + return; + } + + #if DEBUG + Console.WriteLine($"======== CS2 Surf DEBUG >> internal class Checkpoint : PersonalBest -> LoadCheckpointsData -> Checkpoints found for this mapTimeId"); + #endif + + while (results.Read()) + { + #if DEBUG + Console.WriteLine($"cp {results.GetInt32("cp")} "); + Console.WriteLine($"run_time {results.GetFloat("run_time")} "); + Console.WriteLine($"sVelX {results.GetFloat("start_vel_x")} "); + Console.WriteLine($"sVelY {results.GetFloat("start_vel_y")} "); + #endif + + Checkpoint cp = new(results.GetInt32("cp"), + results.GetInt32("run_time"), // To-do: what type of value we use here? DB uses DECIMAL but `.Tick` is int??? + results.GetFloat("start_vel_x"), + results.GetFloat("start_vel_y"), + results.GetFloat("start_vel_z"), + results.GetFloat("end_vel_x"), + results.GetFloat("end_vel_y"), + results.GetFloat("end_vel_z"), + results.GetFloat("end_touch"), + results.GetInt32("attempts")); + cp.ID = results.GetInt32("cp"); + // To-do: cp.ID = calculate Rank # from DB + + PB[0].Checkpoint[cp.CP] = cp; + + #if DEBUG + Console.WriteLine($"======= CS2 Surf DEBUG >> internal class Checkpoint : PersonalBest -> LoadCheckpointsData -> Loaded CP {cp.CP} with RunTime {cp.Ticks}."); + #endif + } + results.Close(); + + #if DEBUG + Console.WriteLine($"======= CS2 Surf DEBUG >> internal class Checkpoint : PersonalBest -> LoadCheckpointsData -> Checkpoints loaded from DB. Count: {PB[0].Checkpoint.Count}"); + #endif + } +} \ No newline at end of file diff --git a/src/ST-Player/PlayerTimer.cs b/src/ST-Player/PlayerTimer.cs index 97df4f4..4d7da97 100644 --- a/src/ST-Player/PlayerTimer.cs +++ b/src/ST-Player/PlayerTimer.cs @@ -3,21 +3,31 @@ 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? + public bool IsEnabled { get; set; } = true; // Enable toggle for entire timer + public bool IsPaused { 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 + public bool IsPracticeMode { get; set; } = false; // Practice mode toggle + public bool IsStageMode { 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 + public int Stage { get; set; } = 0; // Current stage tracker + public int Checkpoint {get; set;} = 0; // Current checkpoint tracker + public CurrentRun CurrentRunData { get; set; } = new CurrentRun(); // Current RUN data tracker + public int Bonus { get; set; } = 0; // To-do: bonus implementation - Current bonus tracker + public int Style { get; set; } = 0; // To-do: functionality for player to change this value and the actual styles implementation - Current style tracker // 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 + 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 + + // Time Formatting + public enum TimeFormatStyle + { + Compact, + Full, + Verbose + } // Methods public void Reset() @@ -25,19 +35,21 @@ public void Reset() this.Stop(); this.Ticks = 0; this.Stage = 0; - this.Paused = false; - this.PracticeMode = false; + this.Checkpoint = 0; + this.IsPaused = false; + this.IsPracticeMode = false; + this.CurrentRunData.Reset(); } public void Pause() { - this.Paused = true; + this.IsPaused = true; } public void Start() { // Timer Start method - notes: OnStartTimerPress - if (this.Enabled) + if (this.IsEnabled) this.IsRunning = true; } @@ -51,9 +63,9 @@ 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) + if (this.IsPaused || !this.IsEnabled || !this.IsRunning) return; - + this.Ticks++; } } \ No newline at end of file diff --git a/src/ST-Player/Replay/ReplayFrame.cs b/src/ST-Player/Replay/ReplayFrame.cs new file mode 100644 index 0000000..f67de67 --- /dev/null +++ b/src/ST-Player/Replay/ReplayFrame.cs @@ -0,0 +1,24 @@ +namespace SurfTimer; +using CounterStrikeSharp.API.Modules.Utils; +using CounterStrikeSharp.API.Core; + +internal enum ReplayFrameSituation +{ + NONE, + START_RUN, + END_RUN, + TOUCH_CHECKPOINT, + START_STAGE, + END_STAGE +} + +[Serializable] +internal class ReplayFrame +{ + public Vector Pos { get; set; } = new Vector(0, 0, 0); + public QAngle Ang { get; set; } = new QAngle(0, 0, 0); + public uint Situation { get; set; } = (uint)ReplayFrameSituation.NONE; + public ulong Button { get; set; } + public uint Flags { get; set; } + public MoveType_t MoveType { get; set; } +} diff --git a/src/ST-Player/Replay/ReplayPlayer.cs b/src/ST-Player/Replay/ReplayPlayer.cs new file mode 100644 index 0000000..98a44c4 --- /dev/null +++ b/src/ST-Player/Replay/ReplayPlayer.cs @@ -0,0 +1,200 @@ +using System.Text; +using System.Text.Json; +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Modules.Utils; +using MySqlConnector; + +namespace SurfTimer; + +internal class ReplayPlayer +{ + public bool IsPlaying { get; set; } = false; + public bool IsPaused { get; set; } = false; + public bool IsPlayable { get; set; } = false; + + // Tracking for replay counting + public int RepeatCount { get; set; } = -1; + + // Stats for replay displaying + public string Stat_Prefix { get; set; } = "WR"; + public string Stat_PlayerName { get; set; } = "N/A"; + public int Stat_MapTimeID { get; set; } = -1; + public int Stat_RunTime { get; set; } = 0; + public bool Stat_IsRunning { get; set; } = false; + public int Stat_RunTick { get; set; } = 0; + + // Tracking + public List Frames { get; set; } = new List(); + + // Playing + public int CurrentFrameTick { get; set; } = 0; + public int FrameTickIncrement { get; set; } = 1; + + public CCSPlayerController? Controller { get; set; } + + public void ResetReplay() + { + this.CurrentFrameTick = 0; + this.FrameTickIncrement = 1; + if(this.RepeatCount > 0) + this.RepeatCount--; + + this.Stat_IsRunning = false; + this.Stat_RunTick = 0; + } + + public void Reset() + { + this.IsPlaying = false; + this.IsPaused = false; + this.IsPlayable = false; + this.RepeatCount = -1; + + this.Frames.Clear(); + + this.ResetReplay(); + + this.Controller = null; + } + + public void SetController(CCSPlayerController c, int repeat_count = -1) + { + this.Controller = c; + this.RepeatCount = repeat_count; + this.IsPlayable = true; + } + + public void Start() + { + if (!this.IsPlayable) + return; + + this.IsPlaying = true; + } + + public void Stop() + { + this.IsPlaying = false; + } + + public void Pause() + { + if (!this.IsPlaying) + return; + + this.IsPaused = !this.IsPaused; + this.Stat_IsRunning = !this.Stat_IsRunning; + } + + public void Tick() + { + if (!this.IsPlaying || !this.IsPlayable || this.Frames.Count == 0) + return; + + ReplayFrame current_frame = this.Frames[this.CurrentFrameTick]; + + // SOME BLASHPEMY FOR YOU + if (this.FrameTickIncrement >= 0) + { + if (current_frame.Situation == (uint)ReplayFrameSituation.START_RUN) + { + this.Stat_IsRunning = true; + this.Stat_RunTick = 0; + } + else if (current_frame.Situation == (uint)ReplayFrameSituation.END_RUN) + { + this.Stat_IsRunning = false; + } + } + else + { + if (current_frame.Situation == (uint)ReplayFrameSituation.START_RUN) + { + this.Stat_IsRunning = false; + } + else if (current_frame.Situation == (uint)ReplayFrameSituation.END_RUN) + { + this.Stat_IsRunning = true; + this.Stat_RunTick = this.CurrentFrameTick - (64*2); // (64*2) counts for the 2 seconds before run actually starts + } + } + // END OF BLASPHEMY + + var current_pos = this.Controller!.PlayerPawn.Value!.AbsOrigin!; + + bool is_on_ground = (current_frame.Flags & (uint)PlayerFlags.FL_ONGROUND) != 0; + + Vector velocity = (current_frame.Pos - current_pos) * 64; + + if (is_on_ground) + this.Controller.PlayerPawn.Value.MoveType = MoveType_t.MOVETYPE_WALK; + else + this.Controller.PlayerPawn.Value.MoveType = MoveType_t.MOVETYPE_NOCLIP; + + if ((current_pos - current_frame.Pos).Length() > 200) + this.Controller.PlayerPawn.Value.Teleport(current_frame.Pos, current_frame.Ang, new Vector(nint.Zero)); + else + this.Controller.PlayerPawn.Value.Teleport(new Vector(nint.Zero), current_frame.Ang, velocity); + + + if (!this.IsPaused) + { + this.CurrentFrameTick = Math.Max(0, this.CurrentFrameTick + this.FrameTickIncrement); + if (this.Stat_IsRunning) + this.Stat_RunTick = Math.Max(0, this.Stat_RunTick + this.FrameTickIncrement); + } + + if(this.CurrentFrameTick >= this.Frames.Count) + this.ResetReplay(); + } + + public void LoadReplayData(TimerDatabase DB) + { + if (!this.IsPlayable) + return; + + Task dbTask = DB.Query($@" + SELECT MapTimes.replay_frames, MapTimes.run_time, Player.name + FROM MapTimes + JOIN Player ON MapTimes.player_id = Player.id + WHERE MapTimes.id={this.Stat_MapTimeID} + "); + + MySqlDataReader mapTimeReplay = dbTask.Result; + if(!mapTimeReplay.HasRows) + { + Console.WriteLine($"CS2 Surf DEBUG >> internal class PlayerReplay -> Load -> No replay data found for Player."); + } + else + { + JsonSerializerOptions options = new JsonSerializerOptions {WriteIndented = false, Converters = { new VectorConverter(), new QAngleConverter() }}; + while(mapTimeReplay.Read()) + { + string json = Compressor.Decompress(Encoding.UTF8.GetString((byte[])mapTimeReplay[0])); + this.Frames = JsonSerializer.Deserialize>(json, options)!; + + this.Stat_RunTime = mapTimeReplay.GetInt32("run_time"); + this.Stat_PlayerName = mapTimeReplay.GetString("name"); + } + FormatBotName(); + } + mapTimeReplay.Close(); + dbTask.Dispose(); + } + + private void FormatBotName() + { + if (!this.IsPlayable) + return; + + SchemaString bot_name = new SchemaString(this.Controller!, "m_iszPlayerName"); + + string replay_name = $"[{this.Stat_Prefix}] {this.Stat_PlayerName} | {PlayerHUD.FormatTime(this.Stat_RunTime)}"; + if(this.Stat_RunTime <= 0) + replay_name = $"[{this.Stat_Prefix}] {this.Stat_PlayerName}"; + + bot_name.Set(replay_name); + Utilities.SetStateChanged(this.Controller!, "CBasePlayerController", "m_iszPlayerName"); + } +} \ No newline at end of file diff --git a/src/ST-Player/Replay/ReplayRecorder.cs b/src/ST-Player/Replay/ReplayRecorder.cs new file mode 100644 index 0000000..fd20ed5 --- /dev/null +++ b/src/ST-Player/Replay/ReplayRecorder.cs @@ -0,0 +1,69 @@ +using System.Text.Json; +using CounterStrikeSharp.API.Modules.Utils; + +namespace SurfTimer; + +internal class ReplayRecorder +{ + public bool IsRecording { get; set; } = false; + public ReplayFrameSituation CurrentSituation { get; set; } = ReplayFrameSituation.NONE; + public List Frames { get; set; } = new List(); + + public void Reset() + { + this.IsRecording = false; + this.Frames.Clear(); + } + + public void Start() + { + this.IsRecording = true; + } + + public void Stop() + { + this.IsRecording = false; + } + + public void Tick(Player player) + { + if (!this.IsRecording || player == null) + return; + + // Disabeling Recording if timer disabled + if (!player.Timer.IsEnabled) + { + this.Stop(); + this.Reset(); + return; + } + + var player_pos = player.Controller.Pawn.Value!.AbsOrigin!; + var player_angle = player.Controller.PlayerPawn.Value!.EyeAngles; + var player_button = player.Controller.Pawn.Value.MovementServices!.Buttons.ButtonStates[0]; + var player_flags = player.Controller.Pawn.Value.Flags; + var player_move_type = player.Controller.Pawn.Value.MoveType; + + var frame = new ReplayFrame + { + Pos = new Vector(player_pos.X, player_pos.Y, player_pos.Z), + Ang = new QAngle(player_angle.X, player_angle.Y, player_angle.Z), + Situation = (uint)this.CurrentSituation, + Button = player_button, + Flags = player_flags, + MoveType = player_move_type, + }; + + this.Frames.Add(frame); + + // Every Situation should last for at most, 1 tick + this.CurrentSituation = ReplayFrameSituation.NONE; + } + + public string SerializeReplay() + { + JsonSerializerOptions options = new JsonSerializerOptions {WriteIndented = false, Converters = { new VectorConverter(), new QAngleConverter() }}; + string replay_frames = JsonSerializer.Serialize(Frames, options); + return Compressor.Compress(replay_frames); + } +} \ No newline at end of file diff --git a/src/ST-Player/Saveloc/SavelocFrame.cs b/src/ST-Player/Saveloc/SavelocFrame.cs new file mode 100644 index 0000000..a6bcd4c --- /dev/null +++ b/src/ST-Player/Saveloc/SavelocFrame.cs @@ -0,0 +1,11 @@ +using CounterStrikeSharp.API.Modules.Utils; + +namespace SurfTimer; + +internal class SavelocFrame +{ + public Vector Pos { get; set; } = new Vector(0, 0, 0); + public QAngle Ang { get; set; } = new QAngle(0, 0, 0); + public Vector Vel { get; set; } = new Vector(0, 0, 0); + public int Tick { get; set; } = 0; +} diff --git a/src/ST-UTILS/Compression.cs b/src/ST-UTILS/Compression.cs new file mode 100644 index 0000000..25ff2b2 --- /dev/null +++ b/src/ST-UTILS/Compression.cs @@ -0,0 +1,161 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using CounterStrikeSharp.API.Modules.Utils; + +namespace SurfTimer; + +internal class VectorConverter : JsonConverter +{ + public override Vector Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // Ensure that the reader is positioned at the start of an object + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException("Expected start of object."); + + float x = 0, y = 0, z = 0; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + break; + + if (reader.TokenType == JsonTokenType.PropertyName) + { + string propertyName = reader.GetString()!; + reader.Read(); + + switch (propertyName) + { + case "X": + x = (float)reader.GetDouble(); + break; + case "Y": + y = (float)reader.GetDouble(); + break; + case "Z": + z = (float)reader.GetDouble(); + break; + } + } + } + + return new Vector { X = x, Y = y, Z = z }; + } + + public override void Write(Utf8JsonWriter writer, Vector value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteNumber("X", value.X); + writer.WriteNumber("Y", value.Y); + writer.WriteNumber("Z", value.Z); + writer.WriteEndObject(); + } +} + +internal class QAngleConverter : JsonConverter +{ + public override QAngle Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // Ensure that the reader is positioned at the start of an object + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException("Expected start of object."); + + float X = 0, Y = 0, Z = 0; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + break; + + if (reader.TokenType == JsonTokenType.PropertyName) + { + string propertyName = reader.GetString()!; + reader.Read(); + + switch (propertyName) + { + case "X": + X = (float)reader.GetDouble(); + break; + case "Y": + Y = (float)reader.GetDouble(); + break; + case "Z": + Z = (float)reader.GetDouble(); + break; + } + } + } + + return new QAngle { X = X, Y = Y, Z = Z }; + } + + public override void Write(Utf8JsonWriter writer, QAngle value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteNumber("X", value.X); + writer.WriteNumber("Y", value.Y); + writer.WriteNumber("Z", value.Z); + writer.WriteEndObject(); + } +} + +internal class Compressor +{ + public static string Decompress(string input) + { + byte[] compressed = Convert.FromBase64String(input); + byte[] decompressed = Decompress(compressed); + return Encoding.UTF8.GetString(decompressed); + } + + public static string Compress(string input) + { + byte[] encoded = Encoding.UTF8.GetBytes(input); + byte[] compressed = Compress(encoded); + return Convert.ToBase64String(compressed); + } + + public static byte[] Decompress(byte[] input) + { + using (var source = new MemoryStream(input)) + { + byte[] lengthBytes = new byte[4]; + source.Read(lengthBytes, 0, 4); + + var length = BitConverter.ToInt32(lengthBytes, 0); + using (var decompressionStream = new GZipStream(source, + CompressionMode.Decompress)) + { + var result = new byte[length]; + int totalRead = 0, bytesRead; + while ((bytesRead = decompressionStream.Read(result, totalRead, length - totalRead)) > 0) + { + totalRead += bytesRead; + } + + return result; + } + } + } + + public static byte[] Compress(byte[] input) + { + using (var result = new MemoryStream()) + { + var lengthBytes = BitConverter.GetBytes(input.Length); + result.Write(lengthBytes, 0, 4); + + using (var compressionStream = new GZipStream(result, + CompressionMode.Compress)) + { + compressionStream.Write(input, 0, input.Length); + compressionStream.Flush(); + + } + return result.ToArray(); + } + } +} \ No newline at end of file diff --git a/src/ST-UTILS/ConVar.cs b/src/ST-UTILS/ConVar.cs new file mode 100644 index 0000000..55206ba --- /dev/null +++ b/src/ST-UTILS/ConVar.cs @@ -0,0 +1,15 @@ +using CounterStrikeSharp.API.Modules.Cvars; + +namespace SurfTimer; + +internal class ConVarHelper +{ + public static void RemoveCheatFlagFromConVar(string cv_name) + { + ConVar? cv = ConVar.Find(cv_name); + if (cv == null || (cv.Flags & CounterStrikeSharp.API.ConVarFlags.FCVAR_CHEAT) == 0) + return; + + cv.Flags &= ~CounterStrikeSharp.API.ConVarFlags.FCVAR_CHEAT; + } +} \ No newline at end of file diff --git a/src/ST-UTILS/Schema.cs b/src/ST-UTILS/Schema.cs new file mode 100644 index 0000000..ba324df --- /dev/null +++ b/src/ST-UTILS/Schema.cs @@ -0,0 +1,30 @@ +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Modules.Memory; + +using System.Runtime.CompilerServices; +using System.Text; + +namespace SurfTimer; +public class SchemaString : NativeObject where SchemaClass : NativeObject +{ + public SchemaString(SchemaClass instance, string member) + : base(Schema.GetSchemaValue(instance.Handle, typeof(SchemaClass).Name!, member)) + { } + + public unsafe void Set(string str) + { + byte[] bytes = this.GetStringBytes(str); + + for (int i = 0; i < bytes.Length; i++) + { + Unsafe.Write((void*)(this.Handle.ToInt64() + i), bytes[i]); + } + + Unsafe.Write((void*)(this.Handle.ToInt64() + bytes.Length), 0); + } + + private byte[] GetStringBytes(string str) + { + return Encoding.UTF8.GetBytes(str); + } +} \ No newline at end of file diff --git a/src/SurfTimer.cs b/src/SurfTimer.cs index b81578a..846a225 100644 --- a/src/SurfTimer.cs +++ b/src/SurfTimer.cs @@ -56,17 +56,30 @@ public partial class SurfTimer : BasePlugin public void OnMapStart(string mapName) { // Initialise Map Object + // To-do: It seems like players connect very quickly and sometimes `CurrentMap` is null when it shouldn't be, lowered the timer ot 1.0 seconds for now if ((CurrentMap == null || CurrentMap.Name != mapName) && mapName.Contains("surf_")) { - AddTimer(3.0f, () => CurrentMap = new Map(mapName, DB!)); + AddTimer(1.0f, () => CurrentMap = new Map(mapName, DB!)); // Was 3 seconds, now 1 second } } + public void OnMapEnd() + { + // Clear/reset stuff here + CurrentMap = null!; + playerList.Clear(); + } + [GameEventHandler] public HookResult OnRoundStart(EventRoundStart @event, GameEventInfo info) { // Load cvars/other configs here // Execute server_settings.cfg + + ConVarHelper.RemoveCheatFlagFromConVar("bot_stop"); + ConVarHelper.RemoveCheatFlagFromConVar("bot_freeze"); + ConVarHelper.RemoveCheatFlagFromConVar("bot_zombie"); + Server.ExecuteCommand("execifexists SurfTimer/server_settings.cfg"); Console.WriteLine("[CS2 Surf] Executed configuration: server_settings.cfg"); return HookResult.Continue; @@ -104,6 +117,8 @@ public override void Load(bool hotReload) // Map Start Hook RegisterListener(OnMapStart); + // Map End Hook + RegisterListener(OnMapEnd); // Tick listener RegisterListener(OnTick); diff --git a/src/SurfTimer.csproj b/src/SurfTimer.csproj index 8d66987..173f3e3 100644 --- a/src/SurfTimer.csproj +++ b/src/SurfTimer.csproj @@ -4,10 +4,16 @@ net7.0 enable enable + true + + + + + DEBUG - +