diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a4d7689..0b1625d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,48 +2,64 @@ name: Build on: push: + branches: + - main tags: - "*" + pull_request: + branches: + - main env: - BUILD_NUMBER: ${{ github.tag }} PROJECT_NAME: "Flashlight" PROJECT_PATH: "./Flashlight/Flashlight.csproj" OUTPUT_PATH: "./Flashlight/Flashlight" PUBLISH_PATH: "./Flashlight/Flashlight" - + DOTNET_VERSION: "10.0.x" jobs: - build: - permissions: write-all + test: runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup .NET - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: - dotnet-version: 7.0.x + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Restore + run: dotnet restore + - name: Test + run: dotnet test -c Release --no-restore + + build: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} - name: Restore run: dotnet restore - name: Build - run: dotnet build ${{ env.PROJECT_PATH }} -c Flashlight -o ${{ env.OUTPUT_PATH }} + run: dotnet build ${{ env.PROJECT_PATH }} -c Release -o ${{ env.OUTPUT_PATH }} publish: - if: github.event_name == 'push' + if: startsWith(github.ref, 'refs/tags/') permissions: write-all runs-on: ubuntu-latest needs: build steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup .NET - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: - dotnet-version: 7.0.x + dotnet-version: ${{ env.DOTNET_VERSION }} - name: Restore run: dotnet restore - name: Build - run: dotnet build ${{ env.PROJECT_PATH }} -c Flashlight -o ${{ env.OUTPUT_PATH }} + run: dotnet build ${{ env.PROJECT_PATH }} -c Release -o ${{ env.OUTPUT_PATH }} - name: Clean files run: | rm -f \ @@ -55,11 +71,12 @@ jobs: type: 'zip' filename: '${{ env.PROJECT_NAME }}.zip' path: ${{ env.PUBLISH_PATH }} - - name: GunGameLite - uses: ncipollo/release-action@v1.12.0 + - name: Create Release + uses: ncipollo/release-action@v1.14.0 with: artifacts: "${{ env.PROJECT_NAME }}.zip" - name: "${{ env.BUILD_NUMBER }}" - tag: "${{ env.BUILD_NUMBER }}" + name: "${{ github.ref_name }}" + tag: "${{ github.ref_name }}" + allowUpdates: true body: | - Place the plugin in game/csgo/addons/counterstrikesharp/plugins/Flashlight \ No newline at end of file + Place the plugin in game/csgo/addons/counterstrikesharp/plugins/Flashlight diff --git a/.gitignore b/.gitignore index 992febe..74439e9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .idea -Flashlight/bin -Flashlight/obj \ No newline at end of file +**/bin/ +**/obj/ +.DS_Store +docs/ diff --git a/Flashlight.Tests/Flashlight.Tests.csproj b/Flashlight.Tests/Flashlight.Tests.csproj new file mode 100644 index 0000000..fd0ff01 --- /dev/null +++ b/Flashlight.Tests/Flashlight.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/Flashlight.Tests/FlashlightLogicTests.cs b/Flashlight.Tests/FlashlightLogicTests.cs new file mode 100644 index 0000000..c42276f --- /dev/null +++ b/Flashlight.Tests/FlashlightLogicTests.cs @@ -0,0 +1,223 @@ +using System.Numerics; +using Xunit; + +namespace Flashlight.Tests; + +public class FlashlightLogicTests +{ + [Fact] + public void TryToggle_TogglesWhenAllowed() + { + var isOn = false; + var canToggle = true; + + Assert.True(FlashlightLogic.TryToggle(ref isOn, ref canToggle)); + Assert.True(isOn); + Assert.False(canToggle); + } + + [Fact] + public void TryToggle_BlockedDuringCooldown() + { + var isOn = true; + var canToggle = false; + + Assert.False(FlashlightLogic.TryToggle(ref isOn, ref canToggle)); + Assert.True(isOn); + Assert.False(canToggle); + } + + [Fact] + public void IsUsePressedEdge_OnlyTrueOnRisingEdge() + { + Assert.True(FlashlightLogic.IsUsePressedEdge(true, false)); + Assert.False(FlashlightLogic.IsUsePressedEdge(true, true)); + Assert.False(FlashlightLogic.IsUsePressedEdge(false, true)); + Assert.False(FlashlightLogic.IsUsePressedEdge(false, false)); + } + + [Theory] + [InlineData(false, 64f, 46f, 64f)] + [InlineData(true, 64f, 46f, 46f)] + public void GetEyeOffsetZ_UsesCrouchWhenDucking( + bool crouching, + float stand, + float crouch, + float expected) + { + Assert.Equal(expected, FlashlightLogic.GetEyeOffsetZ(crouching, stand, crouch)); + } + + [Fact] + public void CalculateLightOrigin_AppliesEyeAndForwardOffsets() + { + var origin = FlashlightLogic.CalculateLightOrigin( + new Vector3(10f, 20f, 30f), + new Vector3(1f, 0f, 0f), + eyeOffsetZ: 64f, + forwardDistance: 54f); + + Assert.Equal(new Vector3(64f, 20f, 94f), origin); + } + + [Fact] + public void ForwardFromAnglesDegrees_YawZeroLooksAlongX() + { + var forward = FlashlightLogic.ForwardFromAnglesDegrees(0f, 0f); + + Assert.Equal(1f, forward.X, 3); + Assert.Equal(0f, forward.Y, 3); + Assert.Equal(0f, forward.Z, 3); + } + + [Fact] + public void ForwardFromAnglesDegrees_PitchDrivesVerticalComponent() + { + // Source angles are inverted on pitch: positive pitch looks down. + Assert.True(FlashlightLogic.ForwardFromAnglesDegrees(45f, 0f).Z < 0f); + Assert.True(FlashlightLogic.ForwardFromAnglesDegrees(-45f, 0f).Z > 0f); + } + + [Fact] + public void HorizontalForwardFromYawDegrees_HasNoVerticalComponent() + { + foreach (var yaw in new[] { -180f, -90f, -45f, 0f, 45f, 90f, 180f }) + { + Assert.Equal(0f, FlashlightLogic.HorizontalForwardFromYawDegrees(yaw).Z, 5); + } + } + + [Fact] + public void CalculateLightTransform_AnglesCarryFullViewRotation() + { + var transform = FlashlightLogic.CalculateLightTransform( + pawnOrigin: new Vector3(0f, 0f, 0f), + pitch: -35f, + yaw: 90f, + roll: 12f, + eyeOffsetZ: 64f, + forwardDistance: 54f); + + // Pitch must survive into the light's angles, otherwise the beam only tracks yaw. + Assert.Equal(-35f, transform.Angles.X, 3); + Assert.Equal(90f, transform.Angles.Y, 3); + Assert.Equal(12f, transform.Angles.Z, 3); + } + + [Theory] + [InlineData(-89f)] + [InlineData(-45f)] + [InlineData(0f)] + [InlineData(45f)] + [InlineData(89f)] + public void CalculateLightTransform_PitchNeverMovesTheOrigin(float pitch) + { + var transform = FlashlightLogic.CalculateLightTransform( + new Vector3(0f, 0f, 0f), + pitch, + yaw: 0f, + roll: 0f, + eyeOffsetZ: 64f, + forwardDistance: 54f); + + // The muzzle stays at eye height and eye-forward regardless of pitch, so + // looking up or down can never shove the light through the ceiling or floor. + Assert.Equal(54f, transform.Origin.X, 3); + Assert.Equal(0f, transform.Origin.Y, 3); + Assert.Equal(64f, transform.Origin.Z, 3); + } + + [Fact] + public void CalculateLightTransform_YawDrivesHorizontalOffset() + { + var transform = FlashlightLogic.CalculateLightTransform( + new Vector3(10f, 20f, 30f), + pitch: 0f, + yaw: 90f, + roll: 0f, + eyeOffsetZ: 64f, + forwardDistance: 54f); + + Assert.Equal(10f, transform.Origin.X, 3); + Assert.Equal(74f, transform.Origin.Y, 3); + Assert.Equal(94f, transform.Origin.Z, 3); + } + + [Fact] + public void ShouldCreateAndDestroyLight_Policies() + { + Assert.True(FlashlightLogic.ShouldCreateLight(isOn: true, hasValidLight: false)); + Assert.False(FlashlightLogic.ShouldCreateLight(isOn: true, hasValidLight: true)); + Assert.True(FlashlightLogic.ShouldDestroyLight(isOn: false, hasValidLight: true)); + Assert.False(FlashlightLogic.ShouldDestroyLight(isOn: false, hasValidLight: false)); + } + + [Fact] + public void ConfigClamp_FixesInvalidValues() + { + var config = new FlashlightConfig + { + ToggleCooldownSeconds = -1f, + Brightness = -5f, + Range = 0f, + ColorTemperature = 50f, + SoftX = -1f, + SoftY = -1f, + Skirt = -1f, + SkirtNear = -1f, + SizeX = -1f, + SizeY = -1f, + SizeZ = -1f, + ForwardDistance = -10f, + StandEyeOffsetZ = -1f, + CrouchEyeOffsetZ = -1f, + LightCookie = "" + }; + + config.Clamp(); + + Assert.Equal(0f, config.ToggleCooldownSeconds); + Assert.Equal(0f, config.Brightness); + Assert.Equal(1f, config.Range); + Assert.Equal(1000f, config.ColorTemperature); + Assert.Equal(0f, config.SoftX); + Assert.Equal(0f, config.SoftY); + Assert.Equal(0f, config.Skirt); + Assert.Equal(0f, config.SkirtNear); + Assert.Equal(0f, config.SizeX); + Assert.Equal(0f, config.SizeY); + Assert.Equal(0f, config.SizeZ); + Assert.Equal(0f, config.ForwardDistance); + Assert.Equal(0f, config.StandEyeOffsetZ); + Assert.Equal(0f, config.CrouchEyeOffsetZ); + Assert.Equal("materials/effects/lightcookies/flashlight.vtex", config.LightCookie); + } + + [Theory] + [InlineData("Any", 2, true)] + [InlineData("Any", 3, true)] + [InlineData("Any", 1, true)] + [InlineData("T", 2, true)] + [InlineData("T", 3, false)] + [InlineData("CT", 3, true)] + [InlineData("CT", 2, false)] + public void IsTeamAllowed_RespectsConfiguredSide(string allowedTeam, byte team, bool expected) + { + Assert.Equal(expected, FlashlightLogic.IsTeamAllowed(allowedTeam, team)); + } + + [Theory] + [InlineData("t", "T")] + [InlineData("Terrorist", "T")] + [InlineData("ct", "CT")] + [InlineData("CounterTerrorist", "CT")] + [InlineData("any", "Any")] + [InlineData("something-else", "Any")] + [InlineData(null, "Any")] + public void ConfigClamp_NormalizesAllowedTeam(string? input, string expected) + { + var config = new FlashlightConfig { AllowedTeam = input! }; + config.Clamp(); + Assert.Equal(expected, config.AllowedTeam); + } +} diff --git a/Flashlight.sln b/Flashlight.sln index 8271bde..3dbb8ef 100644 --- a/Flashlight.sln +++ b/Flashlight.sln @@ -1,7 +1,9 @@ -๏ปฟ + Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flashlight", "Flashlight\Flashlight.csproj", "{D479E900-33D8-4D58-945B-FB2F79DB5742}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flashlight.Tests", "Flashlight.Tests\Flashlight.Tests.csproj", "{A1B2C3D4-1234-5678-90AB-CDEF12345678}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -12,5 +14,9 @@ Global {D479E900-33D8-4D58-945B-FB2F79DB5742}.Debug|Any CPU.Build.0 = Debug|Any CPU {D479E900-33D8-4D58-945B-FB2F79DB5742}.Release|Any CPU.ActiveCfg = Release|Any CPU {D479E900-33D8-4D58-945B-FB2F79DB5742}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-1234-5678-90AB-CDEF12345678}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-1234-5678-90AB-CDEF12345678}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-1234-5678-90AB-CDEF12345678}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-1234-5678-90AB-CDEF12345678}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection -EndGlobal +EndGlobal \ No newline at end of file diff --git a/Flashlight/Flashlight.cs b/Flashlight/Flashlight.cs index 67f1649..f3f6267 100644 --- a/Flashlight/Flashlight.cs +++ b/Flashlight/Flashlight.cs @@ -1,132 +1,149 @@ ๏ปฟusing System.Drawing; +using System.Numerics; using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Attributes; using CounterStrikeSharp.API.Core.Attributes.Registration; using CounterStrikeSharp.API.Modules.Commands; -using Vector = CounterStrikeSharp.API.Modules.Utils.Vector; +using CounterStrikeSharp.API.Modules.Utils; +using Microsoft.Extensions.Logging; namespace Flashlight; -public class Flashlight : BasePlugin +[MinimumApiVersion(371)] +public class FlashlightPlugin : BasePlugin, IPluginConfig { public override string ModuleAuthor => "creazy.eth"; public override string ModuleName => "Flashlight"; public override string ModuleDescription => "Flashlight for Counter-Strike 2"; - public override string ModuleVersion => "0.0.1"; + public override string ModuleVersion => "0.1.2"; - private static string ModuleDisplayName => "Flashlight"; - - // TODO: Change crouch-tracking to a more elegant solution - // TODO: Add config and make light entity values configurable - // TODO: Maybe replace light_omni2 with light_rect or something else + public FlashlightConfig Config { get; set; } = new(); - public static Flashlight? Instance { get; private set; } - - private readonly List _connectedPlayers = new(); - private readonly Dictionary _playerUsingFlashlight = new(); - private readonly Dictionary _playerIsCrouching = new(); - private readonly Dictionary _playerCanToggle = new(); - private readonly Dictionary _playerFlashlight = new(); + private readonly Dictionary _playerStates = new(); + + public void OnConfigParsed(FlashlightConfig config) + { + config.Clamp(); + Config = config; + } public override void Load(bool hotReload) { - LogHelper.LogToConsole(ConsoleColor.Green, $"{ModuleName} version {ModuleVersion} loaded"); + Logger.LogInformation("{Name} v{Version} loading...", ModuleName, ModuleVersion); - Instance = this; - - RegisterListener(() => + RegisterListener(OnTick); + + if (hotReload) { - foreach (var player in _connectedPlayers.Where(player => player is { IsValid: true, IsBot: false, PawnIsAlive: true })) + foreach (var player in Utilities.GetPlayers().Where(IsEligiblePlayer)) { - ToggleFlashlight(player, null); - - if (_playerCanToggle[player] == false) continue; - - if ((player.Buttons & PlayerButtons.Use) != 0) - { - _playerCanToggle[player] = false; - - if (_playerUsingFlashlight[player] == false) - { - _playerUsingFlashlight[player] = true; - - Instance?.AddTimer(0.25f, () => - { - _playerCanToggle[player] = true; - }); - } - else - { - _playerUsingFlashlight[player] = false; - - Instance?.AddTimer(0.25f, () => - { - _playerCanToggle[player] = true; - }); - } - } - - if ((player.Buttons & PlayerButtons.Duck) != 0) - { - _playerIsCrouching[player] = true; - } - else - { - _playerIsCrouching[player] = false; - } + EnsureState(player); } - }); + } - LogHelper.LogToChatAll($"{ModuleDisplayName} v{ModuleVersion} loaded!"); + Logger.LogInformation("{Name} v{Version} loaded!", ModuleName, ModuleVersion); } - + + public override void Unload(bool hotReload) + { + foreach (var slot in _playerStates.Keys.ToList()) + { + DestroyLight(slot); + } + + _playerStates.Clear(); + } + + private void OnTick() + { + if (!Config.Enabled) + { + return; + } + + foreach (var player in Utilities.GetPlayers()) + { + if (!IsEligiblePlayer(player)) + { + continue; + } + + if (!player.PawnIsAlive) + { + // An un-parented light no longer dies together with the pawn, so sweep it up here + // in case a death or round-end never reached the event handlers. + if (_playerStates.TryGetValue(player.Slot, out var deadState) && deadState.IsOn) + { + deadState.IsOn = false; + DestroyLight(player.Slot); + } + + continue; + } + + var state = EnsureState(player); + + if (Config.AllowUseKey) + { + var usePressed = (player.Buttons & PlayerButtons.Use) != 0; + + if (FlashlightLogic.IsUsePressedEdge(usePressed, state.WasUsePressed)) + { + TryToggleFlashlight(player, state); + } + + state.WasUsePressed = usePressed; + } + + if (state.IsOn) + { + UpdateLight(player, state); + } + } + } + [GameEventHandler] public HookResult OnPlayerConnectFull(EventPlayerConnectFull @event, GameEventInfo info) { var player = @event.Userid; - - if (!player.IsValid || player.IsBot) return HookResult.Continue; - - _connectedPlayers.Add(player); - _playerUsingFlashlight[player] = false; - _playerIsCrouching[player] = false; - _playerCanToggle[player] = true; - - LogHelper.LogToConsole(ConsoleColor.Green, $"{player.PlayerName} connected"); - + if (player is null || !IsEligiblePlayer(player)) + { + return HookResult.Continue; + } + + EnsureState(player); + Logger.LogInformation("{Player} connected", player.PlayerName); return HookResult.Continue; } - + [GameEventHandler] public HookResult OnPlayerDisconnect(EventPlayerDisconnect @event, GameEventInfo info) { var player = @event.Userid; - - if (!player.IsValid || player.IsBot) return HookResult.Continue; - - _connectedPlayers.Remove(player); - _playerUsingFlashlight.Remove(player); - _playerIsCrouching.Remove(player); - _playerCanToggle.Remove(player); - - _playerFlashlight.TryGetValue(player, out var flashlight); - flashlight?.Remove(); - _playerFlashlight.Remove(player); - - LogHelper.LogToConsole(ConsoleColor.Green, $"{player.PlayerName} disconnected"); - + if (player is null || !player.IsValid) + { + return HookResult.Continue; + } + + var slot = player.Slot; + DestroyLight(slot); + _playerStates.Remove(slot); return HookResult.Continue; } - + [GameEventHandler] public HookResult OnPlayerSpawn(EventPlayerSpawn @event, GameEventInfo info) { var player = @event.Userid; - - if (!player.IsValid || player.IsBot) return HookResult.Continue; - - _playerUsingFlashlight[player] = false; - + if (player is null || !IsEligiblePlayer(player)) + { + return HookResult.Continue; + } + + var state = EnsureState(player); + state.IsOn = false; + DestroyLight(player.Slot); return HookResult.Continue; } @@ -134,57 +151,218 @@ public class Flashlight : BasePlugin public HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info) { var player = @event.Userid; - - if (!player.IsValid || player.IsBot) return HookResult.Continue; - - _playerUsingFlashlight[player] = false; - + if (player is null || !IsEligiblePlayer(player)) + { + return HookResult.Continue; + } + + var state = EnsureState(player); + state.IsOn = false; + DestroyLight(player.Slot); return HookResult.Continue; } - [ConsoleCommand("fl_toggle", "Toggles the flashlight")] - [CommandHelper(whoCanExecute: CommandUsage.CLIENT_AND_SERVER)] - public void ToggleFlashlight(CCSPlayerController caller, CommandInfo? info) + [GameEventHandler] + public HookResult OnPlayerTeam(EventPlayerTeam @event, GameEventInfo info) { - if (_playerUsingFlashlight[caller] == false) + var player = @event.Userid; + if (player is null || !IsEligiblePlayer(player)) { - if (_playerFlashlight.TryGetValue(caller, out var value)) - { - value.Remove(); - _playerFlashlight.Remove(caller); - } - - return; + return HookResult.Continue; } - - var entity = _playerFlashlight.TryGetValue(caller, out var flashlight) ? flashlight : Utilities.CreateEntityByName("light_omni2"); - if (entity == null || !entity.IsValid) + var state = EnsureState(player); + state.IsOn = false; + DestroyLight(player.Slot); + return HookResult.Continue; + } + + [ConsoleCommand("css_fl_toggle", "Toggles the flashlight")] + [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] + public void OnToggleCommand(CCSPlayerController? caller, CommandInfo info) + { + if (!Config.Enabled || caller is null || !IsEligiblePlayer(caller) || !caller.PawnIsAlive) { - LogHelper.LogToConsole("Failed to create entity!"); return; } - - entity.DirectLight = 3; - - entity.Teleport( - new Vector( - caller.PlayerPawn.Value!.AbsOrigin!.X, - caller.PlayerPawn.Value!.AbsOrigin!.Y, - caller.PlayerPawn.Value!.AbsOrigin!.Z + (_playerIsCrouching[caller] ? 46.03f : 64.03f) - ), - caller.PlayerPawn.Value!.EyeAngles, - caller.PlayerPawn.Value!.AbsVelocity - ); - - entity.OuterAngle = 45f; - entity.Enabled = true; - entity.Color = Color.White; - entity.ColorTemperature = 6500; - entity.Brightness = 1f; - entity.Range = 5000f; - - entity.DispatchSpawn(); - _playerFlashlight[caller] = entity; + + TryToggleFlashlight(caller, EnsureState(caller)); } -} \ No newline at end of file + + private void TryToggleFlashlight(CCSPlayerController player, PlayerFlashlightState state) + { + // Turning on is restricted by AllowedTeam; turning off is always allowed. + if (!state.IsOn && !FlashlightLogic.IsTeamAllowed(Config.AllowedTeam, (byte)player.Team)) + { + return; + } + + var isOn = state.IsOn; + var canToggle = state.CanToggle; + + if (!FlashlightLogic.TryToggle(ref isOn, ref canToggle)) + { + return; + } + + state.IsOn = isOn; + state.CanToggle = canToggle; + + if (state.IsOn) + { + CreateLight(player, state); + } + else + { + DestroyLight(player.Slot); + } + + var slot = player.Slot; + AddTimer(Config.ToggleCooldownSeconds, () => + { + if (_playerStates.TryGetValue(slot, out var current)) + { + current.CanToggle = true; + } + }); + } + + private void CreateLight(CCSPlayerController player, PlayerFlashlightState state) + { + DestroyLight(player.Slot); + + var pawn = player.PlayerPawn.Value; + if (pawn is null || !pawn.IsValid || pawn.AbsOrigin is null || pawn.V_angle is null) + { + Logger.LogWarning("Failed to get pawn data for {Player}", player.PlayerName); + state.IsOn = false; + return; + } + + var light = Utilities.CreateEntityByName("light_barn"); + if (light is null || !light.IsValid) + { + Logger.LogWarning("Failed to create light_barn for {Player}", player.PlayerName); + state.IsOn = false; + return; + } + + light.Enabled = true; + light.Color = Color.FromArgb(255, Config.ColorR, Config.ColorG, Config.ColorB); + light.ColorTemperature = Config.ColorTemperature; + light.Brightness = Config.Brightness; + light.Range = Config.Range; + light.SoftX = Config.SoftX; + light.SoftY = Config.SoftY; + light.Skirt = Config.Skirt; + light.SkirtNear = Config.SkirtNear; + light.CastShadows = Config.CastShadows ? 1 : 0; + light.DirectLight = 3; + light.SizeParams.X = Config.SizeX; + light.SizeParams.Y = Config.SizeY; + light.SizeParams.Z = Config.SizeZ; + + ApplyTransform(light, player, pawn); + + using (var keyValues = new CEntityKeyValues()) + { + keyValues.SetString("lightcookie", Config.LightCookie); + light.DispatchSpawn(keyValues); + } + + state.Light = light; + state.IsOn = true; + } + + /// + /// Keeps the light glued to the player's eye every tick. + /// + /// + /// The light is deliberately not parented to the pawn. Handing it to the engine via + /// SetParent / SetParentAttachmentMaintainOffset locks its orientation to a model + /// attachment, and those attachments only carry the body's yaw, so the beam could never follow + /// the player looking up or down. Driving the transform ourselves keeps pitch and yaw in sync. + /// + private void UpdateLight(CCSPlayerController player, PlayerFlashlightState state) + { + var light = state.Light; + + if (light is null || !light.IsValid) + { + // The engine can reap the entity underneath us (round restart, cleanup); rebuild it. + state.Light = null; + CreateLight(player, state); + return; + } + + var pawn = player.PlayerPawn.Value; + if (pawn is null || !pawn.IsValid || pawn.AbsOrigin is null || pawn.V_angle is null) + { + return; + } + + ApplyTransform(light, player, pawn); + } + + private void ApplyTransform(CBarnLight light, CCSPlayerController player, CCSPlayerPawn pawn) + { + var isCrouching = (player.Buttons & PlayerButtons.Duck) != 0; + var eyeOffsetZ = FlashlightLogic.GetEyeOffsetZ( + isCrouching, + Config.StandEyeOffsetZ, + Config.CrouchEyeOffsetZ); + + var origin = pawn.AbsOrigin!; + var angles = pawn.V_angle; + + var transform = FlashlightLogic.CalculateLightTransform( + new Vector3(origin.X, origin.Y, origin.Z), + angles.X, + angles.Y, + angles.Z, + eyeOffsetZ, + Config.ForwardDistance); + + // Handing the pawn's velocity over lets clients interpolate the light between ticks + // instead of visibly stepping it. + var velocity = pawn.AbsVelocity; + + light.Teleport( + transform.Origin, + transform.Angles, + velocity is null ? null : new Vector3(velocity.X, velocity.Y, velocity.Z)); + } + + private void DestroyLight(int slot) + { + if (!_playerStates.TryGetValue(slot, out var state)) + { + return; + } + + var light = state.Light; + state.Light = null; + + if (light is not null && light.IsValid) + { + light.Remove(); + } + } + + private PlayerFlashlightState EnsureState(CCSPlayerController player) + { + if (_playerStates.TryGetValue(player.Slot, out var state)) + { + return state; + } + + state = new PlayerFlashlightState(); + _playerStates[player.Slot] = state; + return state; + } + + private static bool IsEligiblePlayer(CCSPlayerController? player) + { + return player is { IsValid: true, IsBot: false }; + } +} diff --git a/Flashlight/Flashlight.csproj b/Flashlight/Flashlight.csproj index fb1787e..2be7208 100644 --- a/Flashlight/Flashlight.csproj +++ b/Flashlight/Flashlight.csproj @@ -1,13 +1,13 @@ - net7.0 + net10.0 enable enable - + diff --git a/Flashlight/FlashlightConfig.cs b/Flashlight/FlashlightConfig.cs new file mode 100644 index 0000000..52525d2 --- /dev/null +++ b/Flashlight/FlashlightConfig.cs @@ -0,0 +1,111 @@ +using System.Text.Json.Serialization; +using CounterStrikeSharp.API.Core; + +namespace Flashlight; + +public class FlashlightConfig : BasePluginConfig +{ + [JsonPropertyName("Enabled")] + public bool Enabled { get; set; } = true; + + [JsonPropertyName("AllowUseKey")] + public bool AllowUseKey { get; set; } = true; + + /// + /// Which team may use the flashlight: Any, CT, or T. + /// + [JsonPropertyName("AllowedTeam")] + public string AllowedTeam { get; set; } = "Any"; + + [JsonPropertyName("ToggleCooldownSeconds")] + public float ToggleCooldownSeconds { get; set; } = 0.25f; + + [JsonPropertyName("Brightness")] + public float Brightness { get; set; } = 1.0f; + + [JsonPropertyName("Range")] + public float Range { get; set; } = 2048f; + + [JsonPropertyName("ColorR")] + public byte ColorR { get; set; } = 255; + + [JsonPropertyName("ColorG")] + public byte ColorG { get; set; } = 255; + + [JsonPropertyName("ColorB")] + public byte ColorB { get; set; } = 255; + + [JsonPropertyName("ColorTemperature")] + public float ColorTemperature { get; set; } = 6500f; + + [JsonPropertyName("CastShadows")] + public bool CastShadows { get; set; } = true; + + [JsonPropertyName("SoftX")] + public float SoftX { get; set; } = 1.0f; + + [JsonPropertyName("SoftY")] + public float SoftY { get; set; } = 1.0f; + + [JsonPropertyName("Skirt")] + public float Skirt { get; set; } = 0.5f; + + [JsonPropertyName("SkirtNear")] + public float SkirtNear { get; set; } = 1.0f; + + [JsonPropertyName("SizeX")] + public float SizeX { get; set; } = 45f; + + [JsonPropertyName("SizeY")] + public float SizeY { get; set; } = 45f; + + [JsonPropertyName("SizeZ")] + public float SizeZ { get; set; } = 0.03f; + + [JsonPropertyName("ForwardDistance")] + public float ForwardDistance { get; set; } = 54f; + + [JsonPropertyName("StandEyeOffsetZ")] + public float StandEyeOffsetZ { get; set; } = 64f; + + [JsonPropertyName("CrouchEyeOffsetZ")] + public float CrouchEyeOffsetZ { get; set; } = 46f; + + [JsonPropertyName("LightCookie")] + public string LightCookie { get; set; } = "materials/effects/lightcookies/flashlight.vtex"; + + public void Clamp() + { + ToggleCooldownSeconds = Math.Max(0f, ToggleCooldownSeconds); + Brightness = Math.Max(0f, Brightness); + Range = Math.Max(1f, Range); + ColorTemperature = Math.Clamp(ColorTemperature, 1000f, 12000f); + SoftX = Math.Max(0f, SoftX); + SoftY = Math.Max(0f, SoftY); + Skirt = Math.Max(0f, Skirt); + SkirtNear = Math.Max(0f, SkirtNear); + SizeX = Math.Max(0f, SizeX); + SizeY = Math.Max(0f, SizeY); + SizeZ = Math.Max(0f, SizeZ); + ForwardDistance = Math.Max(0f, ForwardDistance); + StandEyeOffsetZ = Math.Max(0f, StandEyeOffsetZ); + CrouchEyeOffsetZ = Math.Max(0f, CrouchEyeOffsetZ); + + if (string.IsNullOrWhiteSpace(LightCookie)) + { + LightCookie = "materials/effects/lightcookies/flashlight.vtex"; + } + + AllowedTeam = NormalizeAllowedTeam(AllowedTeam); + } + + public static string NormalizeAllowedTeam(string? value) + { + return value?.Trim().ToUpperInvariant() switch + { + "T" or "TERRORIST" or "TERRORISTS" => "T", + "CT" or "COUNTERTERRORIST" or "COUNTERTERRORISTS" or "COUNTER-TERRORIST" or "COUNTER-TERRORISTS" => "CT", + _ => "Any" + }; + } +} diff --git a/Flashlight/FlashlightLogic.cs b/Flashlight/FlashlightLogic.cs new file mode 100644 index 0000000..e0565a9 --- /dev/null +++ b/Flashlight/FlashlightLogic.cs @@ -0,0 +1,115 @@ +using System.Numerics; + +namespace Flashlight; + +/// +/// Position and orientation to apply to the flashlight entity. +/// is a Source QAngle laid out as (pitch, yaw, roll). +/// +public readonly record struct LightTransform(Vector3 Origin, Vector3 Angles); + +public static class FlashlightLogic +{ + public static bool TryToggle(ref bool isOn, ref bool canToggle) + { + if (!canToggle) + { + return false; + } + + isOn = !isOn; + canToggle = false; + return true; + } + + public static bool IsUsePressedEdge(bool usePressed, bool wasUsePressed) + { + return usePressed && !wasUsePressed; + } + + public static float GetEyeOffsetZ(bool isCrouching, float standOffset, float crouchOffset) + { + return isCrouching ? crouchOffset : standOffset; + } + + public static Vector3 CalculateLightOrigin( + Vector3 pawnOrigin, + Vector3 forward, + float eyeOffsetZ, + float forwardDistance) + { + return new Vector3( + pawnOrigin.X + forward.X * forwardDistance, + pawnOrigin.Y + forward.Y * forwardDistance, + pawnOrigin.Z + eyeOffsetZ + forward.Z * forwardDistance); + } + + public static Vector3 ForwardFromAnglesDegrees(float pitch, float yaw) + { + var pitchRad = pitch * (MathF.PI / 180f); + var yawRad = yaw * (MathF.PI / 180f); + var cosPitch = MathF.Cos(pitchRad); + + return new Vector3( + cosPitch * MathF.Cos(yawRad), + cosPitch * MathF.Sin(yawRad), + -MathF.Sin(pitchRad)); + } + + /// + /// Forward vector on the horizontal plane only, ignoring pitch. + /// + public static Vector3 HorizontalForwardFromYawDegrees(float yaw) + { + var yawRad = yaw * (MathF.PI / 180f); + + return new Vector3(MathF.Cos(yawRad), MathF.Sin(yawRad), 0f); + } + + /// + /// World transform for the flashlight given the player's current pawn origin and view angles. + /// + /// + /// The angles carry the full view rotation so the beam tracks pitch as well as yaw, while the + /// origin is only pushed forward on the horizontal plane. Offsetting the origin along the full + /// pitched forward vector would drop the light through the floor when looking straight down + /// (a crouched eye height of 46 minus a 54 unit offset ends up below the ground). + /// + public static LightTransform CalculateLightTransform( + Vector3 pawnOrigin, + float pitch, + float yaw, + float roll, + float eyeOffsetZ, + float forwardDistance) + { + var forward = HorizontalForwardFromYawDegrees(yaw); + var origin = CalculateLightOrigin(pawnOrigin, forward, eyeOffsetZ, forwardDistance); + + return new LightTransform(origin, new Vector3(pitch, yaw, roll)); + } + + public static bool ShouldCreateLight(bool isOn, bool hasValidLight) + { + return isOn && !hasValidLight; + } + + public static bool ShouldDestroyLight(bool isOn, bool hasValidLight) + { + return !isOn && hasValidLight; + } + + /// + /// Returns whether may use the flashlight. + /// Team values match CS2: 2 = Terrorist, 3 = Counter-Terrorist. + /// + public static bool IsTeamAllowed(string allowedTeam, byte team) + { + return allowedTeam switch + { + "T" => team == 2, + "CT" => team == 3, + _ => true + }; + } +} diff --git a/Flashlight/LogHelper.cs b/Flashlight/LogHelper.cs deleted file mode 100644 index 4ccdd0b..0000000 --- a/Flashlight/LogHelper.cs +++ /dev/null @@ -1,56 +0,0 @@ -using CounterStrikeSharp.API; -using CounterStrikeSharp.API.Core; -using CounterStrikeSharp.API.Modules.Utils; - -namespace Flashlight; - -public static class LogHelper -{ - public static void LogToConsole(string messageToLog) - { - Console.WriteLine($"[{Flashlight.Instance?.ModuleName}] -> {messageToLog}"); - } - - public static void LogToConsole(ConsoleColor color, string messageToLog) - { - Console.ForegroundColor = color; - Console.WriteLine($"[{Flashlight.Instance?.ModuleName}] -> {messageToLog}"); - Console.ResetColor(); - } - - public static void LogToChat(CCSPlayerController? player, string messageToLog) - { - player?.PrintToChat($"[{ChatColors.Purple}{Flashlight.Instance?.ModuleName}{ChatColors.Default}]{ReplaceTags(messageToLog + "{DEFAULT}")}"); - } - - public static void LogToChatAll(string messageToLog) - { - Server.PrintToChatAll($"[{ChatColors.Purple}{Flashlight.Instance?.ModuleName}{ChatColors.Default}]{ReplaceTags(messageToLog + "{DEFAULT}")}"); - } - - public static string ReplaceTags(this string text) - { - if (text.StartsWith("{")) text = $" {text}"; - text = text.Replace("{DEFAULT}", $"{ChatColors.Default}"); - text = text.Replace("{WHITE}", $"{ChatColors.White}"); - text = text.Replace("{DARKRED}", $"{ChatColors.Darkred}"); - text = text.Replace("{GREEN}", $"{ChatColors.Green}"); - text = text.Replace("{LIGHTYELLOW}", $"{ChatColors.LightYellow}"); - text = text.Replace("{LIGHTBLUE}", $"{ChatColors.LightBlue}"); - text = text.Replace("{OLIVE}", $"{ChatColors.Olive}"); - text = text.Replace("{LIME}", $"{ChatColors.Lime}"); - text = text.Replace("{RED}", $"{ChatColors.Red}"); - text = text.Replace("{PURPLE}", $"{ChatColors.Purple}"); - text = text.Replace("{GREY}", $"{ChatColors.Grey}"); - text = text.Replace("{YELLOW}", $"{ChatColors.Yellow}"); - text = text.Replace("{GOLD}", $"{ChatColors.Gold}"); - text = text.Replace("{SILVER}", $"{ChatColors.Silver}"); - text = text.Replace("{BLUE}", $"{ChatColors.Blue}"); - text = text.Replace("{DARKBLUE}", $"{ChatColors.DarkBlue}"); - text = text.Replace("{BLUEGREY}", $"{ChatColors.BlueGrey}"); - text = text.Replace("{MAGENTA}", $"{ChatColors.Magenta}"); - text = text.Replace("{LIGHTRED}", $"{ChatColors.LightRed}"); - - return text; - } -} \ No newline at end of file diff --git a/Flashlight/PlayerFlashlightState.cs b/Flashlight/PlayerFlashlightState.cs new file mode 100644 index 0000000..ff2a9e6 --- /dev/null +++ b/Flashlight/PlayerFlashlightState.cs @@ -0,0 +1,18 @@ +using CounterStrikeSharp.API.Core; + +namespace Flashlight; + +public sealed class PlayerFlashlightState +{ + public bool IsOn { get; set; } + public bool CanToggle { get; set; } = true; + public bool WasUsePressed { get; set; } + public CBarnLight? Light { get; set; } + + public void Reset() + { + IsOn = false; + CanToggle = true; + WasUsePressed = false; + } +} diff --git a/README.md b/README.md index d643a58..afb2955 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,114 @@ -# ๐Ÿ”ฆ Flashlight +# Flashlight -Flashlight is a plugin for Counter-Strike 2 that adds a flashlight feature for players. It is written in C# and uses the CounterStrikeSharp API. ๐ŸŽฎ +Flashlight is a Counter-Strike 2 server plugin written in C# with [CounterStrikeSharp](https://docs.cssharp.dev). It gives human players a toggleable flashlight using a `light_barn` entity. -## โญ Features +## Features -- ๐Ÿ’ก Players can toggle the flashlight on and off. -- ๐Ÿ’€ The flashlight is automatically turned off when the player dies or respawns. -- ๐Ÿšซ The flashlight is only available to human players, not bots. +- Toggle with the Use key (`E` by default) or `css_fl_toggle` +- One `light_barn` per player, re-aimed each tick so the beam tracks both pitch and yaw +- Configurable brightness, range, color, shadows, and offsets +- Optional team restriction (`Any`, `CT`, or `T`) +- Automatically turns off on death, spawn, and team change +- Bots ignored -## ๐Ÿ”ง Installation +## Requirements -1. โฌ‡๏ธ Download the latest release from the GitHub repository. -2. ๐Ÿ“ Extract the ZIP file. -3. ๐Ÿ“‚ Place the plugin in the `game/csgo/addons/counterstrikesharp/plugins/Flashlight` directory. +- CounterStrikeSharp **1.0.371+** (API minimum version enforced) +- .NET **10** runtime as provided by your CounterStrikeSharp install -## ๐Ÿ’ป Usage +## Installation -โŒจ๏ธ Use the `Use` key to toggle the flashlight on and off. The default key for this is `E`. +1. Download the latest release ZIP. +2. Extract it. +3. Place the plugin folder in `game/csgo/addons/counterstrikesharp/plugins/Flashlight`. -## ๐Ÿค Contributing +## Usage -Contributions are welcome. Please open an issue or submit a pull request on GitHub. ๐Ÿ™ +- Press Use (`E`) to toggle, if enabled in config. +- Or bind a key: -## ๐Ÿ“ Development Tasks +``` +bind f "css_fl_toggle" +``` -- Change crouch-tracking to a more elegant solution -- Add config and make light entity values configurable -- Maybe replace light_omni2 with light_rect or something else +## Configuration -## ๐Ÿ“ƒ License +On first load, CounterStrikeSharp writes a JSON config for the plugin. Defaults: -This project is licensed under the GNU General Public License. โš–๏ธ +| Key | Default | Description | +| --- | --- | --- | +| `Enabled` | `true` | Master switch | +| `AllowUseKey` | `true` | Allow Use-key toggle | +| `AllowedTeam` | `Any` | Who may use it: `Any`, `CT`, or `T` | +| `ToggleCooldownSeconds` | `0.25` | Toggle cooldown | +| `Brightness` | `1.0` | Light brightness | +| `Range` | `2048` | Light range | +| `ColorR` / `ColorG` / `ColorB` | `255` | Light color | +| `ColorTemperature` | `6500` | Kelvin temperature | +| `CastShadows` | `true` | Cast dynamic shadows | +| `SoftX` / `SoftY` | `1.0` | Softness | +| `Skirt` / `SkirtNear` | `0.5` / `1.0` | Skirt falloff | +| `SizeX` / `SizeY` / `SizeZ` | `45` / `45` / `0.03` | Beam size params | +| `ForwardDistance` | `54` | Horizontal offset in front of the eye, so the beam clears the player model | +| `StandEyeOffsetZ` | `64` | Standing eye height offset | +| `CrouchEyeOffsetZ` | `46` | Crouching eye height offset | +| `LightCookie` | `materials/effects/lightcookies/flashlight.vtex` | Flashlight cookie texture | -## โœ๏ธ Author +## Development -This project was created by [creazy.eth](https://github.com/creazy231) \ No newline at end of file +### Prerequisites + +- .NET 10 SDK +- CounterStrikeSharp.API 1.0.371+ + +### Build + +```bash +dotnet restore +dotnet build +``` + +### Test + +```bash +dotnet test +``` + +Unit tests cover toggle/cooldown logic, Use-key edge detection, transform math (origin and pitch/yaw angles), create/destroy policy, and config clamping. Entity behaviour itself requires a live CS2 server. + +## Changelog + +### v0.1.2 + +- Fixed the beam only following horizontal aim: the light was parented to the pawn's `axis_of_intent` attachment, which carries body yaw but not view pitch, so looking up or down never moved it. The light is now un-parented and re-aimed every tick from the pawn's live `V_angle`. +- Fixed the flashlight never updating when `AllowUseKey` was `false`, which previously short-circuited the whole tick loop. +- `ForwardDistance` now offsets the light horizontally only, so looking straight down no longer pushes it through the floor. +- Removed the obsolete `AttachmentName` config key (leaving it in an existing config file is harmless and ignored). + +### v0.1.1 + +- Updated to .NET 10 and CounterStrikeSharp.API 1.0.371 +- Replaced per-tick `light_omni2` spawn/teleport with parented `light_barn` +- Added `IPluginConfig` settings for light and toggle behavior +- Added `AllowedTeam` config (`Any` / `CT` / `T`) to restrict flashlight by side +- Added focused xUnit tests for pure helpers +- Updated GitHub Actions for .NET 10, PR tests, and tag releases +- Switched logging to `BasePlugin.Logger` + +### v0.0.6 + +- Updated to .NET 8.0 and CounterStrikeSharp.API v1.0.363 +- Added initial xUnit test project +- Improved CSS API compatibility (`V_angle`, entity validity checks) + +### v0.0.5 + +- Initial release + +## License + +GNU General Public License. See `LICENSE`. + +## Author + +[creazy.eth](https://github.com/creazy231)