From a0ef6311eef2007b4d78e6e035a5f629078d1df0 Mon Sep 17 00:00:00 2001 From: Tobias Thiele Date: Tue, 21 Jul 2026 07:26:30 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=94=A6=20Modernize=20flashlight=20for?= =?UTF-8?q?=20CSS/.NET=2010?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump to CounterStrikeSharp 1.0.371 and .NET 10, replace per-tick light spawns with a parented light_barn, add plugin config, real unit tests, and refresh CI plus docs. Co-authored-by: Cursor --- .github/workflows/build.yml | 50 +- .gitignore | 5 +- Flashlight.Tests/Flashlight.Tests.csproj | 13 +- Flashlight.Tests/FlashlightLogicTests.cs | 235 +++++----- Flashlight/Flashlight.cs | 429 ++++++++++-------- Flashlight/Flashlight.csproj | 4 +- Flashlight/FlashlightConfig.cs | 101 +++++ Flashlight/FlashlightLogic.cs | 62 +++ Flashlight/LogHelper.cs | 56 --- Flashlight/PlayerFlashlightState.cs | 18 + README.md | 99 ++-- .../2026-07-21-flashlight-modernization.md | 61 +++ ...6-07-21-flashlight-modernization-design.md | 148 ++++++ 13 files changed, 847 insertions(+), 434 deletions(-) create mode 100644 Flashlight/FlashlightConfig.cs create mode 100644 Flashlight/FlashlightLogic.cs delete mode 100644 Flashlight/LogHelper.cs create mode 100644 Flashlight/PlayerFlashlightState.cs create mode 100644 docs/superpowers/plans/2026-07-21-flashlight-modernization.md create mode 100644 docs/superpowers/specs/2026-07-21-flashlight-modernization-design.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b79b2cc..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 \ @@ -56,11 +72,11 @@ jobs: filename: '${{ env.PROJECT_NAME }}.zip' path: ${{ env.PUBLISH_PATH }} - name: Create Release - uses: ncipollo/release-action@v1.12.0 + 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 diff --git a/.gitignore b/.gitignore index 992febe..1a95523 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea -Flashlight/bin -Flashlight/obj \ No newline at end of file +**/bin/ +**/obj/ +.DS_Store \ No newline at end of file diff --git a/Flashlight.Tests/Flashlight.Tests.csproj b/Flashlight.Tests/Flashlight.Tests.csproj index f4a8da4..fd0ff01 100644 --- a/Flashlight.Tests/Flashlight.Tests.csproj +++ b/Flashlight.Tests/Flashlight.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable false @@ -9,21 +9,20 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - \ No newline at end of file + diff --git a/Flashlight.Tests/FlashlightLogicTests.cs b/Flashlight.Tests/FlashlightLogicTests.cs index 7219fca..06e0d47 100644 --- a/Flashlight.Tests/FlashlightLogicTests.cs +++ b/Flashlight.Tests/FlashlightLogicTests.cs @@ -1,3 +1,4 @@ +using System.Numerics; using Xunit; namespace Flashlight.Tests; @@ -5,137 +6,119 @@ namespace Flashlight.Tests; public class FlashlightLogicTests { [Fact] - public void FlashlightState_TogglesCorrectly() + public void TryToggle_TogglesWhenAllowed() { - // Test the core logic of flashlight state toggling - var playerKey = "test_player_1"; - var flashlightState = new Dictionary(); - - // Initial state should be off - flashlightState[playerKey] = false; - Assert.False(flashlightState[playerKey]); - - // Toggle on - flashlightState[playerKey] = !flashlightState[playerKey]; - Assert.True(flashlightState[playerKey]); - - // Toggle off - flashlightState[playerKey] = !flashlightState[playerKey]; - Assert.False(flashlightState[playerKey]); - } - - [Fact] - public void FlashlightState_TracksMultiplePlayers() - { - var playerStates = new Dictionary(); - var player1 = "player_1"; - var player2 = "player_2"; - var player3 = "player_3"; - - // Initialize all off - playerStates[player1] = false; - playerStates[player2] = false; - playerStates[player3] = false; - - // Toggle player 1 on - playerStates[player1] = !playerStates[player1]; - Assert.True(playerStates[player1]); - Assert.False(playerStates[player2]); - Assert.False(playerStates[player3]); - - // Toggle player 2 on - playerStates[player2] = !playerStates[player2]; - Assert.True(playerStates[player1]); - Assert.True(playerStates[player2]); - Assert.False(playerStates[player3]); - } - - [Fact] - public void ToggleCooldown_PreventsRapidToggling() - { - // Simulate the cooldown mechanism + var isOn = false; var canToggle = true; - - // First toggle - should work - Assert.True(canToggle); - canToggle = false; // Simulate setting cooldown - - // Second toggle - should be blocked + + Assert.True(FlashlightLogic.TryToggle(ref isOn, ref canToggle)); + Assert.True(isOn); Assert.False(canToggle); - - // After cooldown expires - canToggle = true; - Assert.True(canToggle); } - + [Fact] - public void CrouchState_UpdatesCorrectly() + public void TryToggle_BlockedDuringCooldown() { - var isCrouching = false; - var buttons = 0; - const int DuckButton = 1 << 2; // Typical duck button bit - - // Not crouching initially - Assert.False(isCrouching); - - // Press duck button - buttons |= DuckButton; - if ((buttons & DuckButton) != 0) + 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 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 { - isCrouching = true; - } - Assert.True(isCrouching); - - // Release duck button - buttons &= ~DuckButton; - if ((buttons & DuckButton) == 0) - { - isCrouching = false; - } - Assert.False(isCrouching); + 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, + AttachmentName = " ", + 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("axis_of_intent", config.AttachmentName); + Assert.Equal("materials/effects/lightcookies/flashlight.vtex", config.LightCookie); } - - [Fact] - public void LightPosition_CalculatesCrouchOffsetCorrectly() - { - // Test the position calculation logic - var baseZ = 100f; - var standOffset = 64.03f; - var crouchOffset = 46.03f; - - // Standing position - var standPosition = baseZ + standOffset; - Assert.Equal(164.03f, standPosition); - - // Crouching position - var crouchPosition = baseZ + crouchOffset; - Assert.Equal(146.03f, crouchPosition); - } - - [Fact] - public void FlashlightEntity_Management() - { - // Test entity tracking dictionary behavior - var playerEntities = new Dictionary(); - var playerKey = "test_player"; - - // No entity initially - Assert.False(playerEntities.TryGetValue(playerKey, out _)); - - // Add entity - var light = new FakeLightEntity { IsValid = true }; - playerEntities[playerKey] = light; - Assert.True(playerEntities.TryGetValue(playerKey, out var retrieved)); - Assert.True(retrieved?.IsValid); - - // Remove entity - playerEntities.Remove(playerKey); - Assert.False(playerEntities.TryGetValue(playerKey, out _)); - } - - private class FakeLightEntity - { - public bool IsValid { get; set; } - public void Remove() => IsValid = false; - } -} \ No newline at end of file +} diff --git a/Flashlight/Flashlight.cs b/Flashlight/Flashlight.cs index f162ba7..75935b9 100644 --- a/Flashlight/Flashlight.cs +++ b/Flashlight/Flashlight.cs @@ -1,149 +1,127 @@ ๏ปฟ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; -[MinimumApiVersion(363)] -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.7"; + public override string ModuleVersion => "0.1.0"; - 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 - // FIXED: EyeAngles -> V_angle for CSS API v1.0.363+ compatibility - // FIXED: Entity validity check before Remove() to prevent crashes on team switch (Issue #2) + 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) { - Instance = this; - - LogHelper.LogToConsole(ConsoleColor.Green, $"{ModuleName} v{ModuleVersion} loading..."); - - RegisterListener(() => - { - foreach (var player in _connectedPlayers.Where(player => player is { IsValid: true, IsBot: false, PawnIsAlive: true })) - { - ToggleFlashlight(player); - - if (_playerCanToggle[player] == false) continue; - - if ((player.Buttons & PlayerButtons.Use) != 0) - { - _playerCanToggle[player] = false; - var currentPlayer = player; // Capture for closure - - if (_playerUsingFlashlight[player] == false) - { - _playerUsingFlashlight[player] = true; - - Instance?.AddTimer(0.25f, () => - { - _playerCanToggle[currentPlayer] = true; - }); - } - else - { - _playerUsingFlashlight[player] = false; - - Instance?.AddTimer(0.25f, () => - { - _playerCanToggle[currentPlayer] = true; - }); - } - } - - if ((player.Buttons & PlayerButtons.Duck) != 0) - { - _playerIsCrouching[player] = true; - } - else - { - _playerIsCrouching[player] = false; - } - } - }); + Logger.LogInformation("{Name} v{Version} loading...", ModuleName, ModuleVersion); - LogHelper.LogToConsole($"{ModuleDisplayName} v{ModuleVersion} loaded!"); + RegisterListener(OnTick); + + if (hotReload) + { + foreach (var player in Utilities.GetPlayers().Where(IsEligiblePlayer)) + { + EnsureState(player); + } + } + + 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 || !Config.AllowUseKey) + { + return; + } + + foreach (var player in Utilities.GetPlayers()) + { + if (!IsEligiblePlayer(player) || !player.PawnIsAlive) + { + continue; + } + + var state = EnsureState(player); + var usePressed = (player.Buttons & PlayerButtons.Use) != 0; + + if (FlashlightLogic.IsUsePressedEdge(usePressed, state.WasUsePressed)) + { + TryToggleFlashlight(player, state); + } + + state.WasUsePressed = usePressed; + } + } + [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); - // Fix #2: Check entity validity before removing - if (flashlight != null && flashlight.IsValid) + if (player is null || !player.IsValid) { - flashlight.Remove(); + return HookResult.Continue; } - _playerFlashlight.Remove(player); - - LogHelper.LogToConsole(ConsoleColor.Green, $"{player.PlayerName} disconnected"); - + + 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; - - if (_connectedPlayers.Contains(player) == false) + if (player is null || !IsEligiblePlayer(player)) { - _connectedPlayers.Add(player); - _playerUsingFlashlight[player] = false; - _playerIsCrouching[player] = false; - _playerCanToggle[player] = true; + return HookResult.Continue; } - - _playerUsingFlashlight[player] = false; - + + var state = EnsureState(player); + state.IsOn = false; + DestroyLight(player.Slot); return HookResult.Continue; } @@ -151,108 +129,173 @@ 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; } [GameEventHandler] public HookResult OnPlayerTeam(EventPlayerTeam @event, GameEventInfo info) { - // Fix #2: Handle team switch - clean up flashlight entity var player = @event.Userid; - - if (!player.IsValid || player.IsBot) return HookResult.Continue; - - // Turn off flashlight and clean up entity when switching teams - _playerUsingFlashlight[player] = false; - - if (_playerFlashlight.TryGetValue(player, out var flashlight)) + if (player is null || !IsEligiblePlayer(player)) { - if (flashlight != null && flashlight.IsValid) - { - flashlight.Remove(); - } - _playerFlashlight.Remove(player); + return HookResult.Continue; } - + + var state = EnsureState(player); + state.IsOn = false; + DestroyLight(player.Slot); return HookResult.Continue; } - public void ToggleFlashlight(CCSPlayerController player) - { - if (_playerUsingFlashlight[player] == false) - { - if (_playerFlashlight.TryGetValue(player, out var value)) - { - // Fix #2: Check entity validity before removing - if (value.IsValid) - { - value.Remove(); - } - _playerFlashlight.Remove(player); - } - - return; - } - - var entity = _playerFlashlight.TryGetValue(player, out var flashlight) ? flashlight : Utilities.CreateEntityByName("light_omni2"); - - if (entity == null || !entity.IsValid) - { - LogHelper.LogToConsole("Failed to create entity!"); - return; - } - - entity.DirectLight = 3; - - var pawn = player.PlayerPawn.Value; - if (pawn?.AbsOrigin == null || pawn.V_angle == null) - { - LogHelper.LogToConsole("Failed to get player pawn data!"); - return; - } - - entity.Teleport( - new Vector( - pawn.AbsOrigin.X, - pawn.AbsOrigin.Y, - pawn.AbsOrigin.Z + (_playerIsCrouching[player] ? 46.03f : 64.03f) - ), - pawn.V_angle, - pawn.AbsVelocity - ); - - entity.OuterAngle = 45f; - entity.Enabled = true; - entity.Color = Color.White; - entity.ColorTemperature = 6500; - entity.Brightness = 1f; - entity.Range = 5000f; - - entity.DispatchSpawn(); - _playerFlashlight[player] = entity; - } - [ConsoleCommand("css_fl_toggle", "Toggles the flashlight")] [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] - public void ToggleFlashlight(CCSPlayerController caller, CommandInfo? info) + public void OnToggleCommand(CCSPlayerController? caller, CommandInfo info) { - if (!caller.IsValid || !caller.PawnIsAlive) return; - - if (_playerCanToggle[caller] == false) return; - - _playerUsingFlashlight[caller] = !_playerUsingFlashlight[caller]; - _playerCanToggle[caller] = false; - - var currentCaller = caller; // Capture for closure - - Instance?.AddTimer(0.25f, () => + if (!Config.Enabled || caller is null || !IsEligiblePlayer(caller) || !caller.PawnIsAlive) { - _playerCanToggle[currentCaller] = true; + return; + } + + TryToggleFlashlight(caller, EnsureState(caller)); + } + + private void TryToggleFlashlight(CCSPlayerController player, PlayerFlashlightState state) + { + 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) + { + CreateAndParentLight(player, state); + } + else + { + DestroyLight(player.Slot); + } + + var slot = player.Slot; + AddTimer(Config.ToggleCooldownSeconds, () => + { + if (_playerStates.TryGetValue(slot, out var current)) + { + current.CanToggle = true; + } }); } -} \ No newline at end of file + + private void CreateAndParentLight(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; + } + + var isCrouching = (player.Buttons & PlayerButtons.Duck) != 0; + var eyeOffsetZ = FlashlightLogic.GetEyeOffsetZ( + isCrouching, + Config.StandEyeOffsetZ, + Config.CrouchEyeOffsetZ); + + var angles = pawn.V_angle; + var forward = FlashlightLogic.ForwardFromAnglesDegrees(angles.X, angles.Y); + var origin = FlashlightLogic.CalculateLightOrigin( + new Vector3(pawn.AbsOrigin.X, pawn.AbsOrigin.Y, pawn.AbsOrigin.Z), + forward, + eyeOffsetZ, + Config.ForwardDistance); + + 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; + + light.Teleport( + origin, + new Vector3(angles.X, angles.Y, angles.Z), + null); + + using (var keyValues = new CEntityKeyValues()) + { + keyValues.SetString("lightcookie", Config.LightCookie); + light.DispatchSpawn(keyValues); + } + + light.AcceptInput("SetParent", pawn, light, "!activator"); + light.AcceptInput("SetParentAttachmentMaintainOffset", null, null, Config.AttachmentName); + + state.Light = light; + state.IsOn = true; + } + + 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 5b93a8e..2be7208 100644 --- a/Flashlight/Flashlight.csproj +++ b/Flashlight/Flashlight.csproj @@ -1,13 +1,13 @@ - net8.0 + net10.0 enable enable - + diff --git a/Flashlight/FlashlightConfig.cs b/Flashlight/FlashlightConfig.cs new file mode 100644 index 0000000..14c131f --- /dev/null +++ b/Flashlight/FlashlightConfig.cs @@ -0,0 +1,101 @@ +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; + + [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("AttachmentName")] + public string AttachmentName { get; set; } = "axis_of_intent"; + + [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(AttachmentName)) + { + AttachmentName = "axis_of_intent"; + } + + if (string.IsNullOrWhiteSpace(LightCookie)) + { + LightCookie = "materials/effects/lightcookies/flashlight.vtex"; + } + } +} diff --git a/Flashlight/FlashlightLogic.cs b/Flashlight/FlashlightLogic.cs new file mode 100644 index 0000000..f938225 --- /dev/null +++ b/Flashlight/FlashlightLogic.cs @@ -0,0 +1,62 @@ +using System.Numerics; + +namespace Flashlight; + +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)); + } + + public static bool ShouldCreateLight(bool isOn, bool hasValidLight) + { + return isOn && !hasValidLight; + } + + public static bool ShouldDestroyLight(bool isOn, bool hasValidLight) + { + return !isOn && hasValidLight; + } +} 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 885ce66..f866d8e 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,105 @@ -# ๐Ÿ”ฆ 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 parented `light_barn` entity. -## โญ Features +## Features -- ๐Ÿ’ก Players can toggle the flashlight on and off using `Use` key (the default key for this is `E`) or `/fl_toggle` in chat which could be bound to a different key. -- ๐Ÿ’€ 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, parented to the pawn attachment (no per-tick spawn/teleport) +- Configurable brightness, range, color, shadows, offsets, and attachment +- 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`. Or use the `/fl_toggle` command in chat which could then be bound to a different key. +1. Download the latest release ZIP. +2. Extract it. +3. Place the plugin folder in `game/csgo/addons/counterstrikesharp/plugins/Flashlight`. + +## Usage + +- Press Use (`E`) to toggle, if enabled in config. +- Or bind a key: -Example bind: ``` bind f "css_fl_toggle" ``` -## ๐Ÿ› ๏ธ Development +## Configuration + +On first load, CounterStrikeSharp writes a JSON config for the plugin. Defaults: + +| Key | Default | Description | +| --- | --- | --- | +| `Enabled` | `true` | Master switch | +| `AllowUseKey` | `true` | Allow Use-key toggle | +| `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` | Spawn offset along view forward | +| `StandEyeOffsetZ` | `64` | Standing eye height offset | +| `CrouchEyeOffsetZ` | `46` | Crouching eye height offset | +| `AttachmentName` | `axis_of_intent` | Parent attachment | +| `LightCookie` | `materials/effects/lightcookies/flashlight.vtex` | Flashlight cookie texture | + +## Development ### Prerequisites -- .NET 8.0 SDK -- CounterStrikeSharp API v1.0.363+ +- .NET 10 SDK +- CounterStrikeSharp.API 1.0.371+ -### Building +### Build ```bash dotnet restore dotnet build ``` -### Testing +### Test ```bash dotnet test ``` -## ๐Ÿค Contributing +Unit tests cover toggle/cooldown logic, Use-key edge detection, origin math, create/destroy policy, and config clamping. Entity parenting requires a live CS2 server. -Contributions are welcome. Please open an issue or submit a pull request on GitHub. ๐Ÿ™ +## Changelog -## ๐Ÿ“‹ Changelog +### v0.1.0 -### v0.0.6 (Latest) -- โœ… Updated to .NET 8.0 -- โœ… Updated to CounterStrikeSharp.API v1.0.363 -- โœ… Added xUnit test project with core logic tests -- โœ… Updated GitHub Actions workflow with testing -- โœ… Improved code compatibility with latest CS2/CSS API +- 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 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 +## License -This project is licensed under the GNU General Public License. โš–๏ธ +GNU General Public License. See `LICENSE`. -## โœ๏ธ Author +## Author -This project was created by [creazy.eth](https://github.com/creazy231) \ No newline at end of file +[creazy.eth](https://github.com/creazy231) diff --git a/docs/superpowers/plans/2026-07-21-flashlight-modernization.md b/docs/superpowers/plans/2026-07-21-flashlight-modernization.md new file mode 100644 index 0000000..edbae99 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-flashlight-modernization.md @@ -0,0 +1,61 @@ +# Flashlight Modernization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Modernize the flashlight plugin to .NET 10 / CSS 1.0.371 with a parented `light_barn` implementation, config, real unit tests, fixed CI, and updated README. + +**Architecture:** Pure helpers in `FlashlightLogic` + config clamps in `FlashlightConfig`; plugin owns lifecycle and wires Use/command/events; one `CBarnLight` per active player, parented once, toggled via create/destroy. + +**Tech Stack:** .NET 10, CounterStrikeSharp.API 1.0.371, xUnit, GitHub Actions + +## Global Constraints + +- Target framework: `net10.0` +- Package: `CounterStrikeSharp.API` `1.0.371` +- `[MinimumApiVersion(371)]` +- Module version: `0.1.0` +- No per-tick entity create/spawn/teleport in steady state +- Spec: `docs/superpowers/specs/2026-07-21-flashlight-modernization-design.md` + +--- + +### Task 1: Project targets + pure logic + tests + +**Files:** +- Modify: `Flashlight/Flashlight.csproj` +- Modify: `Flashlight.Tests/Flashlight.Tests.csproj` +- Create: `Flashlight/FlashlightConfig.cs` +- Create: `Flashlight/FlashlightLogic.cs` +- Create: `Flashlight/PlayerFlashlightState.cs` +- Modify: `Flashlight.Tests/FlashlightLogicTests.cs` + +- [x] Retarget both projects to `net10.0`; bump CSS to `1.0.371`; bump test packages as needed +- [x] Implement `FlashlightConfig` with defaults from the spec and a `Clamp()` method +- [x] Implement pure `FlashlightLogic` helpers: try-toggle with cooldown, eye Z offset, origin from base+forward +- [x] Replace smoke tests with tests against those helpers +- [x] Run `dotnet test` and confirm pass + +### Task 2: Plugin rewrite (parented light_barn) + +**Files:** +- Modify: `Flashlight/Flashlight.cs` +- Delete or gut: `Flashlight/LogHelper.cs` (prefer `Logger`) + +- [x] Implement `BasePlugin, IPluginConfig` +- [x] OnTick: Use-key edge + cooldown only when `AllowUseKey` +- [x] Create/parent/enable `light_barn` once on toggle on; remove on toggle off +- [x] Cleanup on death/spawn/team/disconnect/unload +- [x] Keep `css_fl_toggle` +- [x] Run `dotnet build` and confirm success + +### Task 3: CI + README + +**Files:** +- Modify: `.github/workflows/build.yml` +- Modify: `README.md` + +- [x] CI: .NET 10, restore, test, release build +- [x] README: versions, parented light, config table, build/test, changelog 0.1.0 +- [x] Run `dotnet test` once more + +--- diff --git a/docs/superpowers/specs/2026-07-21-flashlight-modernization-design.md b/docs/superpowers/specs/2026-07-21-flashlight-modernization-design.md new file mode 100644 index 0000000..dcc2079 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-flashlight-modernization-design.md @@ -0,0 +1,148 @@ +# Flashlight Plugin Modernization Design + +**Date:** 2026-07-21 +**Status:** Approved +**Version target:** 0.1.0 + +## Goal + +Modernize the CounterStrikeSharp flashlight plugin to the latest CSS/.NET stack, replace the per-tick spawn/teleport light path with a parented `light_barn` implementation, add `IPluginConfig` for admin tuning, improve testability, and update the README. + +## Background + +The current plugin (`Flashlight` v0.0.7) targets CounterStrikeSharp.API `1.0.363` on .NET 8. While the flashlight is on, `OnTick` creates a `light_omni2` and calls `DispatchSpawn` every server frame. That is the dominant performance problem. Existing tests only exercise inlined dictionary/bool logic and do not cover extractable plugin helpers. CI still installs .NET 7. + +Latest CounterStrikeSharp.API is `1.0.371` and targets .NET 10. CS2Fixes demonstrates the preferred flashlight pattern: spawn `light_barn` once, set a flashlight lightcookie via entity keyvalues, parent to the player pawn attachment, and toggle enablement rather than teleporting every tick. + +## Decisions + +| Topic | Decision | +| --- | --- | +| Runtime | .NET 10 + CounterStrikeSharp.API 1.0.371 | +| Light entity | `light_barn` (`CBarnLight`), parented once | +| Position updates | Engine parenting; no per-tick teleport | +| Config | `IPluginConfig` with JSON config | +| Logging | Prefer `BasePlugin.Logger` | +| README | Fully updated for new runtime, behavior, config, build/test | +| Module version | 0.1.0 | + +## Architecture + +``` +FlashlightPlugin (BasePlugin, IPluginConfig) + โ”œโ”€โ”€ FlashlightConfig // JSON-backed settings + clamps + โ”œโ”€โ”€ PlayerFlashlightState // per-player flags + entity handle + โ”œโ”€โ”€ FlashlightService // create / parent / enable / destroy + โ””โ”€โ”€ FlashlightLogic // pure helpers (toggle, cooldown, offsets) +``` + +### Runtime flow + +1. `OnConfigParsed` validates/clamps config. +2. `Load` registers `OnTick` (Use-key edge + cooldown only), game event handlers, and command `css_fl_toggle`. +3. When a player turns the light on: + - Create `CBarnLight` via `Utilities.CreateEntityByName("light_barn")`. + - Apply config (brightness, range, color, temperature, soft/skirt/size, cast shadows, direct light). + - Compute initial origin: pawn origin + eye Z offset + forward * `ForwardDistance`. + - `Teleport` once using `System.Numerics.Vector3` overloads (avoid legacy `Vector` allocs). + - `DispatchSpawn(CEntityKeyValues)` with `lightcookie` = configured path (default flashlight vtex). + - `AcceptInput("SetParent", pawn, โ€ฆ)` then `AcceptInput("SetParentAttachmentMaintainOffset", โ€ฆ, AttachmentName)`. + - Set `Enabled = true`. +4. When turned off: set `Enabled = false` and remove the entity (or disable and keep โ€” prefer remove to avoid orphaned entities across pawn changes). +5. Cleanup on death, team change, disconnect, and plugin unload. + +### Why not keep tick teleport? + +Parenting follows view/attachment with far less managed work and no entity churn. Tick work is limited to scanning connected humans for Use-button edges and cooldown expiry. + +## Config surface + +File written/loaded by CSS config system (standard plugin config JSON). + +| Key | Type | Default | Notes | +| --- | --- | --- | --- | +| `Enabled` | bool | `true` | Master switch | +| `AllowUseKey` | bool | `true` | Toggle via Use (`E`) | +| `ToggleCooldownSeconds` | float | `0.25` | Clamp โ‰ฅ 0 | +| `Brightness` | float | `1.0` | | +| `Range` | float | `2048` | Match CS2Fixes-style defaults | +| `ColorR` / `ColorG` / `ColorB` | byte | `255` | White | +| `ColorTemperature` | float | `6500` | | +| `CastShadows` | bool | `true` | Maps to `CastShadows` int | +| `SoftX` / `SoftY` | float | `1.0` | | +| `Skirt` | float | `0.5` | | +| `SkirtNear` | float | `1.0` | | +| `SizeX` / `SizeY` / `SizeZ` | float | `45` / `45` / `0.03` | `SizeParams` | +| `ForwardDistance` | float | `54` | Avoid AWP blocking beam | +| `StandEyeOffsetZ` | float | `64` | | +| `CrouchEyeOffsetZ` | float | `46` | Used at spawn time only | +| `AttachmentName` | string | `axis_of_intent` | Parent attachment | +| `LightCookie` | string | `materials/effects/lightcookies/flashlight.vtex` | | + +Invalid values are clamped or rejected in `OnConfigParsed` with log warnings; plugin remains loadable when possible. + +## Player state + +Replace multiple `Dictionary` maps with one structure keyed by player slot (or controller), holding: + +- `IsOn` +- `CanToggle` +- `Light` (`CBarnLight?`) + +Crouch tracking for continuous Z updates is unnecessary once the light is parented; crouch offset is only applied at creation time. Optional: read duck state at spawn for initial Z only. + +## Event / command behavior (unchanged UX) + +- Use key toggles when `AllowUseKey` is true and cooldown allows. +- `css_fl_toggle` remains client-only command alternative. +- Flashlight turns off on death and spawn; entity cleaned on team change and disconnect. +- Bots ignored. + +## Testing strategy + +Full CSS entity lifecycle cannot be unit-tested without a game server. Extract and test pure logic: + +1. Toggle state transitions and cooldown gating. +2. Config clamp helpers (range, cooldown, color channels). +3. Initial position offset calculation (stand/crouch Z + forward distance given basis vectors). +4. Enable/disable policy: when entity should be created vs destroyed. + +Use xUnit on .NET 10. Keep Moq only if needed; prefer plain helpers over mocking CSS types. + +## CI / packaging + +- GitHub Actions: .NET 10 SDK, `dotnet restore`, `dotnet test`, release build on tags. +- Remove stale .NET 7 setup. +- Publish zip layout unchanged: `plugins/Flashlight/`. + +## README updates + +- Prerequisites: .NET 10, CSS 1.0.371+. +- Behavior: parented `light_barn`, Use + command. +- Config table with defaults. +- Build / test instructions. +- Changelog entry for 0.1.0 (API bump, performance rewrite, config, tests, CI). + +## Out of scope + +- Particle flashlight mode (CS2Fixes mode 2). +- Admin permissions / VIP-only flashlight. +- Client-side HUD indicators. +- Migrating to CounterStrikeSharp 2.0 alpha. + +## Risks / mitigations + +| Risk | Mitigation | +| --- | --- | +| Attachment name missing on some models | Configurable `AttachmentName`; fall back to parent-only if attachment input fails | +| `CEntityKeyValues` lightcookie path differs | Use CS2Fixes-proven path; document override | +| .NET 10 server prerequisite | Document clearly; MinimumApiVersion 371 | +| Parenting breaks on pawn swap | Recreate light on spawn; destroy on death/team | + +## Success criteria + +- Builds against CounterStrikeSharp.API 1.0.371 on net10.0. +- No entity create/spawn/teleport in the steady-state OnTick path. +- Config file generated and honored. +- Unit tests cover pure helpers and pass in CI. +- README matches shipped behavior and versions. From c7779740c73d15cc140caae5adf0d736e17e0a45 Mon Sep 17 00:00:00 2001 From: Tobias Thiele Date: Tue, 21 Jul 2026 07:34:09 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20Add=20AllowedTeam=20an?= =?UTF-8?q?d=20bump=20to=200.1.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restrict flashlight by CT/T/Any via config, drop tracked docs, and ship the 0.1.1 version bump. Co-authored-by: Cursor --- .gitignore | 3 +- Flashlight.Tests/FlashlightLogicTests.cs | 28 ++++ Flashlight/Flashlight.cs | 8 +- Flashlight/FlashlightConfig.cs | 18 +++ Flashlight/FlashlightLogic.cs | 14 ++ README.md | 5 +- .../2026-07-21-flashlight-modernization.md | 61 -------- ...6-07-21-flashlight-modernization-design.md | 148 ------------------ 8 files changed, 73 insertions(+), 212 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-21-flashlight-modernization.md delete mode 100644 docs/superpowers/specs/2026-07-21-flashlight-modernization-design.md diff --git a/.gitignore b/.gitignore index 1a95523..74439e9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea **/bin/ **/obj/ -.DS_Store \ No newline at end of file +.DS_Store +docs/ diff --git a/Flashlight.Tests/FlashlightLogicTests.cs b/Flashlight.Tests/FlashlightLogicTests.cs index 06e0d47..5276d5c 100644 --- a/Flashlight.Tests/FlashlightLogicTests.cs +++ b/Flashlight.Tests/FlashlightLogicTests.cs @@ -121,4 +121,32 @@ public class FlashlightLogicTests Assert.Equal("axis_of_intent", config.AttachmentName); 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/Flashlight.cs b/Flashlight/Flashlight.cs index 75935b9..910c8a9 100644 --- a/Flashlight/Flashlight.cs +++ b/Flashlight/Flashlight.cs @@ -16,7 +16,7 @@ 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.1.0"; + public override string ModuleVersion => "0.1.1"; public FlashlightConfig Config { get; set; } = new(); @@ -169,6 +169,12 @@ public class FlashlightPlugin : BasePlugin, IPluginConfig 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; diff --git a/Flashlight/FlashlightConfig.cs b/Flashlight/FlashlightConfig.cs index 14c131f..3da2af8 100644 --- a/Flashlight/FlashlightConfig.cs +++ b/Flashlight/FlashlightConfig.cs @@ -11,6 +11,12 @@ public class FlashlightConfig : BasePluginConfig [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; @@ -97,5 +103,17 @@ public class FlashlightConfig : BasePluginConfig { 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 index f938225..7827943 100644 --- a/Flashlight/FlashlightLogic.cs +++ b/Flashlight/FlashlightLogic.cs @@ -59,4 +59,18 @@ public static class FlashlightLogic { 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/README.md b/README.md index f866d8e..93a1018 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Flashlight is a Counter-Strike 2 server plugin written in C# with [CounterStrike - Toggle with the Use key (`E` by default) or `css_fl_toggle` - One `light_barn` per player, parented to the pawn attachment (no per-tick spawn/teleport) - Configurable brightness, range, color, shadows, offsets, and attachment +- Optional team restriction (`Any`, `CT`, or `T`) - Automatically turns off on death, spawn, and team change - Bots ignored @@ -38,6 +39,7 @@ On first load, CounterStrikeSharp writes a JSON config for the plugin. Defaults: | --- | --- | --- | | `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 | @@ -77,11 +79,12 @@ Unit tests cover toggle/cooldown logic, Use-key edge detection, origin math, cre ## Changelog -### v0.1.0 +### 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` diff --git a/docs/superpowers/plans/2026-07-21-flashlight-modernization.md b/docs/superpowers/plans/2026-07-21-flashlight-modernization.md deleted file mode 100644 index edbae99..0000000 --- a/docs/superpowers/plans/2026-07-21-flashlight-modernization.md +++ /dev/null @@ -1,61 +0,0 @@ -# Flashlight Modernization Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Modernize the flashlight plugin to .NET 10 / CSS 1.0.371 with a parented `light_barn` implementation, config, real unit tests, fixed CI, and updated README. - -**Architecture:** Pure helpers in `FlashlightLogic` + config clamps in `FlashlightConfig`; plugin owns lifecycle and wires Use/command/events; one `CBarnLight` per active player, parented once, toggled via create/destroy. - -**Tech Stack:** .NET 10, CounterStrikeSharp.API 1.0.371, xUnit, GitHub Actions - -## Global Constraints - -- Target framework: `net10.0` -- Package: `CounterStrikeSharp.API` `1.0.371` -- `[MinimumApiVersion(371)]` -- Module version: `0.1.0` -- No per-tick entity create/spawn/teleport in steady state -- Spec: `docs/superpowers/specs/2026-07-21-flashlight-modernization-design.md` - ---- - -### Task 1: Project targets + pure logic + tests - -**Files:** -- Modify: `Flashlight/Flashlight.csproj` -- Modify: `Flashlight.Tests/Flashlight.Tests.csproj` -- Create: `Flashlight/FlashlightConfig.cs` -- Create: `Flashlight/FlashlightLogic.cs` -- Create: `Flashlight/PlayerFlashlightState.cs` -- Modify: `Flashlight.Tests/FlashlightLogicTests.cs` - -- [x] Retarget both projects to `net10.0`; bump CSS to `1.0.371`; bump test packages as needed -- [x] Implement `FlashlightConfig` with defaults from the spec and a `Clamp()` method -- [x] Implement pure `FlashlightLogic` helpers: try-toggle with cooldown, eye Z offset, origin from base+forward -- [x] Replace smoke tests with tests against those helpers -- [x] Run `dotnet test` and confirm pass - -### Task 2: Plugin rewrite (parented light_barn) - -**Files:** -- Modify: `Flashlight/Flashlight.cs` -- Delete or gut: `Flashlight/LogHelper.cs` (prefer `Logger`) - -- [x] Implement `BasePlugin, IPluginConfig` -- [x] OnTick: Use-key edge + cooldown only when `AllowUseKey` -- [x] Create/parent/enable `light_barn` once on toggle on; remove on toggle off -- [x] Cleanup on death/spawn/team/disconnect/unload -- [x] Keep `css_fl_toggle` -- [x] Run `dotnet build` and confirm success - -### Task 3: CI + README - -**Files:** -- Modify: `.github/workflows/build.yml` -- Modify: `README.md` - -- [x] CI: .NET 10, restore, test, release build -- [x] README: versions, parented light, config table, build/test, changelog 0.1.0 -- [x] Run `dotnet test` once more - ---- diff --git a/docs/superpowers/specs/2026-07-21-flashlight-modernization-design.md b/docs/superpowers/specs/2026-07-21-flashlight-modernization-design.md deleted file mode 100644 index dcc2079..0000000 --- a/docs/superpowers/specs/2026-07-21-flashlight-modernization-design.md +++ /dev/null @@ -1,148 +0,0 @@ -# Flashlight Plugin Modernization Design - -**Date:** 2026-07-21 -**Status:** Approved -**Version target:** 0.1.0 - -## Goal - -Modernize the CounterStrikeSharp flashlight plugin to the latest CSS/.NET stack, replace the per-tick spawn/teleport light path with a parented `light_barn` implementation, add `IPluginConfig` for admin tuning, improve testability, and update the README. - -## Background - -The current plugin (`Flashlight` v0.0.7) targets CounterStrikeSharp.API `1.0.363` on .NET 8. While the flashlight is on, `OnTick` creates a `light_omni2` and calls `DispatchSpawn` every server frame. That is the dominant performance problem. Existing tests only exercise inlined dictionary/bool logic and do not cover extractable plugin helpers. CI still installs .NET 7. - -Latest CounterStrikeSharp.API is `1.0.371` and targets .NET 10. CS2Fixes demonstrates the preferred flashlight pattern: spawn `light_barn` once, set a flashlight lightcookie via entity keyvalues, parent to the player pawn attachment, and toggle enablement rather than teleporting every tick. - -## Decisions - -| Topic | Decision | -| --- | --- | -| Runtime | .NET 10 + CounterStrikeSharp.API 1.0.371 | -| Light entity | `light_barn` (`CBarnLight`), parented once | -| Position updates | Engine parenting; no per-tick teleport | -| Config | `IPluginConfig` with JSON config | -| Logging | Prefer `BasePlugin.Logger` | -| README | Fully updated for new runtime, behavior, config, build/test | -| Module version | 0.1.0 | - -## Architecture - -``` -FlashlightPlugin (BasePlugin, IPluginConfig) - โ”œโ”€โ”€ FlashlightConfig // JSON-backed settings + clamps - โ”œโ”€โ”€ PlayerFlashlightState // per-player flags + entity handle - โ”œโ”€โ”€ FlashlightService // create / parent / enable / destroy - โ””โ”€โ”€ FlashlightLogic // pure helpers (toggle, cooldown, offsets) -``` - -### Runtime flow - -1. `OnConfigParsed` validates/clamps config. -2. `Load` registers `OnTick` (Use-key edge + cooldown only), game event handlers, and command `css_fl_toggle`. -3. When a player turns the light on: - - Create `CBarnLight` via `Utilities.CreateEntityByName("light_barn")`. - - Apply config (brightness, range, color, temperature, soft/skirt/size, cast shadows, direct light). - - Compute initial origin: pawn origin + eye Z offset + forward * `ForwardDistance`. - - `Teleport` once using `System.Numerics.Vector3` overloads (avoid legacy `Vector` allocs). - - `DispatchSpawn(CEntityKeyValues)` with `lightcookie` = configured path (default flashlight vtex). - - `AcceptInput("SetParent", pawn, โ€ฆ)` then `AcceptInput("SetParentAttachmentMaintainOffset", โ€ฆ, AttachmentName)`. - - Set `Enabled = true`. -4. When turned off: set `Enabled = false` and remove the entity (or disable and keep โ€” prefer remove to avoid orphaned entities across pawn changes). -5. Cleanup on death, team change, disconnect, and plugin unload. - -### Why not keep tick teleport? - -Parenting follows view/attachment with far less managed work and no entity churn. Tick work is limited to scanning connected humans for Use-button edges and cooldown expiry. - -## Config surface - -File written/loaded by CSS config system (standard plugin config JSON). - -| Key | Type | Default | Notes | -| --- | --- | --- | --- | -| `Enabled` | bool | `true` | Master switch | -| `AllowUseKey` | bool | `true` | Toggle via Use (`E`) | -| `ToggleCooldownSeconds` | float | `0.25` | Clamp โ‰ฅ 0 | -| `Brightness` | float | `1.0` | | -| `Range` | float | `2048` | Match CS2Fixes-style defaults | -| `ColorR` / `ColorG` / `ColorB` | byte | `255` | White | -| `ColorTemperature` | float | `6500` | | -| `CastShadows` | bool | `true` | Maps to `CastShadows` int | -| `SoftX` / `SoftY` | float | `1.0` | | -| `Skirt` | float | `0.5` | | -| `SkirtNear` | float | `1.0` | | -| `SizeX` / `SizeY` / `SizeZ` | float | `45` / `45` / `0.03` | `SizeParams` | -| `ForwardDistance` | float | `54` | Avoid AWP blocking beam | -| `StandEyeOffsetZ` | float | `64` | | -| `CrouchEyeOffsetZ` | float | `46` | Used at spawn time only | -| `AttachmentName` | string | `axis_of_intent` | Parent attachment | -| `LightCookie` | string | `materials/effects/lightcookies/flashlight.vtex` | | - -Invalid values are clamped or rejected in `OnConfigParsed` with log warnings; plugin remains loadable when possible. - -## Player state - -Replace multiple `Dictionary` maps with one structure keyed by player slot (or controller), holding: - -- `IsOn` -- `CanToggle` -- `Light` (`CBarnLight?`) - -Crouch tracking for continuous Z updates is unnecessary once the light is parented; crouch offset is only applied at creation time. Optional: read duck state at spawn for initial Z only. - -## Event / command behavior (unchanged UX) - -- Use key toggles when `AllowUseKey` is true and cooldown allows. -- `css_fl_toggle` remains client-only command alternative. -- Flashlight turns off on death and spawn; entity cleaned on team change and disconnect. -- Bots ignored. - -## Testing strategy - -Full CSS entity lifecycle cannot be unit-tested without a game server. Extract and test pure logic: - -1. Toggle state transitions and cooldown gating. -2. Config clamp helpers (range, cooldown, color channels). -3. Initial position offset calculation (stand/crouch Z + forward distance given basis vectors). -4. Enable/disable policy: when entity should be created vs destroyed. - -Use xUnit on .NET 10. Keep Moq only if needed; prefer plain helpers over mocking CSS types. - -## CI / packaging - -- GitHub Actions: .NET 10 SDK, `dotnet restore`, `dotnet test`, release build on tags. -- Remove stale .NET 7 setup. -- Publish zip layout unchanged: `plugins/Flashlight/`. - -## README updates - -- Prerequisites: .NET 10, CSS 1.0.371+. -- Behavior: parented `light_barn`, Use + command. -- Config table with defaults. -- Build / test instructions. -- Changelog entry for 0.1.0 (API bump, performance rewrite, config, tests, CI). - -## Out of scope - -- Particle flashlight mode (CS2Fixes mode 2). -- Admin permissions / VIP-only flashlight. -- Client-side HUD indicators. -- Migrating to CounterStrikeSharp 2.0 alpha. - -## Risks / mitigations - -| Risk | Mitigation | -| --- | --- | -| Attachment name missing on some models | Configurable `AttachmentName`; fall back to parent-only if attachment input fails | -| `CEntityKeyValues` lightcookie path differs | Use CS2Fixes-proven path; document override | -| .NET 10 server prerequisite | Document clearly; MinimumApiVersion 371 | -| Parenting breaks on pawn swap | Recreate light on spawn; destroy on death/team | - -## Success criteria - -- Builds against CounterStrikeSharp.API 1.0.371 on net10.0. -- No entity create/spawn/teleport in the steady-state OnTick path. -- Config file generated and honored. -- Unit tests cover pure helpers and pass in CI. -- README matches shipped behavior and versions. From b43f9fdea39837785f3a1d68c124205186f7cae7 Mon Sep 17 00:00:00 2001 From: Tobias Thiele Date: Mon, 27 Jul 2026 08:50:29 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=94=A6=20Fix=20flashlight=20beam=20no?= =?UTF-8?q?t=20tracking=20view=20pitch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The light was positioned once at toggle time and then handed to the engine via SetParent + SetParentAttachmentMaintainOffset on the pawn's `axis_of_intent` attachment. That attachment carries the body's yaw but not the view pitch, so once parented the beam could only ever rotate horizontally โ€” looking up or down did nothing. Drop the parenting and drive the light's transform ourselves every tick from the pawn's live AbsOrigin and V_angle, which is how the plugin behaved before 0.1.1 replaced the per-tick teleport with a parented entity. Also: - OnTick no longer short-circuits on !AllowUseKey, which otherwise stopped the light updating for servers that only expose css_fl_toggle. - ForwardDistance now offsets the light horizontally only. Applying it along the pitched forward vector put the light below ground when looking straight down (crouched eye height 46 minus 54). - Sweep up the light when the pawn is dead; an un-parented entity no longer dies with its pawn. - Remove the now-unused AttachmentName config key. Co-Authored-By: Claude Opus 5 (1M context) --- Flashlight.Tests/FlashlightLogicTests.cs | 75 +++++++++++++- Flashlight/Flashlight.cs | 125 +++++++++++++++++------ Flashlight/FlashlightConfig.cs | 8 -- Flashlight/FlashlightLogic.cs | 39 +++++++ README.md | 18 ++-- 5 files changed, 217 insertions(+), 48 deletions(-) diff --git a/Flashlight.Tests/FlashlightLogicTests.cs b/Flashlight.Tests/FlashlightLogicTests.cs index 5276d5c..c42276f 100644 --- a/Flashlight.Tests/FlashlightLogicTests.cs +++ b/Flashlight.Tests/FlashlightLogicTests.cs @@ -70,6 +70,79 @@ public class FlashlightLogicTests 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() { @@ -98,7 +171,6 @@ public class FlashlightLogicTests ForwardDistance = -10f, StandEyeOffsetZ = -1f, CrouchEyeOffsetZ = -1f, - AttachmentName = " ", LightCookie = "" }; @@ -118,7 +190,6 @@ public class FlashlightLogicTests Assert.Equal(0f, config.ForwardDistance); Assert.Equal(0f, config.StandEyeOffsetZ); Assert.Equal(0f, config.CrouchEyeOffsetZ); - Assert.Equal("axis_of_intent", config.AttachmentName); Assert.Equal("materials/effects/lightcookies/flashlight.vtex", config.LightCookie); } diff --git a/Flashlight/Flashlight.cs b/Flashlight/Flashlight.cs index 910c8a9..f3f6267 100644 --- a/Flashlight/Flashlight.cs +++ b/Flashlight/Flashlight.cs @@ -16,7 +16,7 @@ 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.1.1"; + public override string ModuleVersion => "0.1.2"; public FlashlightConfig Config { get; set; } = new(); @@ -57,27 +57,49 @@ public class FlashlightPlugin : BasePlugin, IPluginConfig private void OnTick() { - if (!Config.Enabled || !Config.AllowUseKey) + if (!Config.Enabled) { return; } foreach (var player in Utilities.GetPlayers()) { - if (!IsEligiblePlayer(player) || !player.PawnIsAlive) + if (!IsEligiblePlayer(player)) { continue; } - var state = EnsureState(player); - var usePressed = (player.Buttons & PlayerButtons.Use) != 0; - - if (FlashlightLogic.IsUsePressedEdge(usePressed, state.WasUsePressed)) + if (!player.PawnIsAlive) { - TryToggleFlashlight(player, state); + // 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; } - state.WasUsePressed = usePressed; + 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); + } } } @@ -188,7 +210,7 @@ public class FlashlightPlugin : BasePlugin, IPluginConfig if (state.IsOn) { - CreateAndParentLight(player, state); + CreateLight(player, state); } else { @@ -205,7 +227,7 @@ public class FlashlightPlugin : BasePlugin, IPluginConfig }); } - private void CreateAndParentLight(CCSPlayerController player, PlayerFlashlightState state) + private void CreateLight(CCSPlayerController player, PlayerFlashlightState state) { DestroyLight(player.Slot); @@ -225,20 +247,6 @@ public class FlashlightPlugin : BasePlugin, IPluginConfig return; } - var isCrouching = (player.Buttons & PlayerButtons.Duck) != 0; - var eyeOffsetZ = FlashlightLogic.GetEyeOffsetZ( - isCrouching, - Config.StandEyeOffsetZ, - Config.CrouchEyeOffsetZ); - - var angles = pawn.V_angle; - var forward = FlashlightLogic.ForwardFromAnglesDegrees(angles.X, angles.Y); - var origin = FlashlightLogic.CalculateLightOrigin( - new Vector3(pawn.AbsOrigin.X, pawn.AbsOrigin.Y, pawn.AbsOrigin.Z), - forward, - eyeOffsetZ, - Config.ForwardDistance); - light.Enabled = true; light.Color = Color.FromArgb(255, Config.ColorR, Config.ColorG, Config.ColorB); light.ColorTemperature = Config.ColorTemperature; @@ -254,10 +262,7 @@ public class FlashlightPlugin : BasePlugin, IPluginConfig light.SizeParams.Y = Config.SizeY; light.SizeParams.Z = Config.SizeZ; - light.Teleport( - origin, - new Vector3(angles.X, angles.Y, angles.Z), - null); + ApplyTransform(light, player, pawn); using (var keyValues = new CEntityKeyValues()) { @@ -265,13 +270,69 @@ public class FlashlightPlugin : BasePlugin, IPluginConfig light.DispatchSpawn(keyValues); } - light.AcceptInput("SetParent", pawn, light, "!activator"); - light.AcceptInput("SetParentAttachmentMaintainOffset", null, null, Config.AttachmentName); - 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)) diff --git a/Flashlight/FlashlightConfig.cs b/Flashlight/FlashlightConfig.cs index 3da2af8..52525d2 100644 --- a/Flashlight/FlashlightConfig.cs +++ b/Flashlight/FlashlightConfig.cs @@ -71,9 +71,6 @@ public class FlashlightConfig : BasePluginConfig [JsonPropertyName("CrouchEyeOffsetZ")] public float CrouchEyeOffsetZ { get; set; } = 46f; - [JsonPropertyName("AttachmentName")] - public string AttachmentName { get; set; } = "axis_of_intent"; - [JsonPropertyName("LightCookie")] public string LightCookie { get; set; } = "materials/effects/lightcookies/flashlight.vtex"; @@ -94,11 +91,6 @@ public class FlashlightConfig : BasePluginConfig StandEyeOffsetZ = Math.Max(0f, StandEyeOffsetZ); CrouchEyeOffsetZ = Math.Max(0f, CrouchEyeOffsetZ); - if (string.IsNullOrWhiteSpace(AttachmentName)) - { - AttachmentName = "axis_of_intent"; - } - if (string.IsNullOrWhiteSpace(LightCookie)) { LightCookie = "materials/effects/lightcookies/flashlight.vtex"; diff --git a/Flashlight/FlashlightLogic.cs b/Flashlight/FlashlightLogic.cs index 7827943..e0565a9 100644 --- a/Flashlight/FlashlightLogic.cs +++ b/Flashlight/FlashlightLogic.cs @@ -2,6 +2,12 @@ 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) @@ -50,6 +56,39 @@ public static class FlashlightLogic -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; diff --git a/README.md b/README.md index 93a1018..afb2955 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Flashlight -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 parented `light_barn` entity. +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 - Toggle with the Use key (`E` by default) or `css_fl_toggle` -- One `light_barn` per player, parented to the pawn attachment (no per-tick spawn/teleport) -- Configurable brightness, range, color, shadows, offsets, and attachment +- 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 @@ -49,10 +49,9 @@ On first load, CounterStrikeSharp writes a JSON config for the plugin. Defaults: | `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` | Spawn offset along view forward | +| `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 | -| `AttachmentName` | `axis_of_intent` | Parent attachment | | `LightCookie` | `materials/effects/lightcookies/flashlight.vtex` | Flashlight cookie texture | ## Development @@ -75,10 +74,17 @@ dotnet build dotnet test ``` -Unit tests cover toggle/cooldown logic, Use-key edge detection, origin math, create/destroy policy, and config clamping. Entity parenting requires a live CS2 server. +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