Compare commits

..

15 commits
v0.0.3 ... main

Author SHA1 Message Date
Tobias Thiele
b43f9fdea3 🔦 Fix flashlight beam not tracking view pitch
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) <noreply@anthropic.com>
2026-07-27 08:50:29 +02:00
Tobias Thiele
b3bbc2dc9a
Merge pull request #9 from creazy231/cursor/flashlight-modernization-net10
Modernize flashlight for CSS/.NET 10 + team config
2026-07-21 07:34:28 +02:00
Tobias Thiele
c7779740c7 ⚙️ Add AllowedTeam and bump to 0.1.1
Restrict flashlight by CT/T/Any via config, drop tracked docs, and
ship the 0.1.1 version bump.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 07:34:09 +02:00
Tobias Thiele
6f46cee3c1
Merge pull request #8 from creazy231/cursor/flashlight-modernization-net10
🔦 Modernize flashlight for CSS/.NET 10
2026-07-21 07:29:29 +02:00
Tobias Thiele
a0ef6311ee 🔦 Modernize flashlight for CSS/.NET 10
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 <cursoragent@cursor.com>
2026-07-21 07:26:30 +02:00
Vesper
081278a8ae fix(ci): allow release updates and fix step name
- Add allowUpdates: true to fix 'tag_name already exists' error
- Rename 'GunGameLite' step to 'Create Release' (copy-paste artifact)
2026-03-01 09:02:22 +00:00
Vesper
1b54187c0f
Merge PR #7: Fix entity validity check - fixes #2
Add IsValid checks before Remove() to prevent crashes when players switch teams

- Check entity validity in ToggleFlashlight()
- Check entity validity in OnPlayerDisconnect()
- Add OnPlayerTeam handler for team switch cleanup
- Bump version to 0.0.7
2026-02-28 22:21:24 +00:00
Vesper
57e502fbf6 fix: Entity validity check before Remove() - fixes #2
Add IsValid checks before calling Remove() on flashlight entities to prevent
InvalidOperationException when players switch teams.

Changes:
- Add validity check in ToggleFlashlight() before entity.Remove()
- Add validity check in OnPlayerDisconnect() before flashlight.Remove()
- Add OnPlayerTeam event handler to clean up flashlight on team switch
- Bump version to 0.0.7

This prevents the 'Entity is not valid' crash reported in Issue #2 when
players switch teams and their entities become invalid.
2026-02-28 22:21:00 +00:00
Vesper
806a3a9156
Merge PR #6: Update to latest CS2/CSS API and .NET 8.0
- Update to .NET 8.0 and CounterStrikeSharp.API v1.0.363
- Fix EyeAngles -> V_angle for Issue #5
- Fix closure capture bug in timer callbacks
- Add xUnit test project
- Update documentation
2026-02-28 22:17:58 +00:00
Vesper
bd2ad516f4 feat: Update to latest CS2/CSS API and .NET 8.0
- Update TargetFramework from net7.0 to net8.0
- Update CounterStrikeSharp.API from 1.0.126 to 1.0.363
- Update MinimumApiVersion from 126 to 363
- Bump ModuleVersion to 0.0.6
- FIX: Replace deprecated EyeAngles with V_angle (fixes #5)
- FIX: Fix closure capture bug in timer callbacks (race condition)
- Add null checks for player pawn data
- Add xUnit test project with core logic tests
- Update solution file to include test project
- Update README with development instructions and changelog

Workflow update will be handled separately due to GitHub token scope requirements.

This update ensures compatibility with the latest Counter-Strike 2
and CounterStrikeSharp API versions.
2026-02-28 22:16:15 +00:00
Tobias Thiele
91f7dd4f69 💡 Update module version to 0.0.5 in Flashlight.cs 2023-12-14 06:53:39 +01:00
Tobias Thiele
ea40c1a5ba 💡 ✏️ Update CounterStrikeSharp.API version to 1.0.126
- Updated the CounterStrikeSharp.API version in the Flashlight.csproj file to 1.0.126.
2023-12-14 06:53:12 +01:00
Tobias Thiele
95c459f931 Fix server crash on some systems 2023-12-13 17:57:52 +01:00
Tobias Thiele
f5a81d0b50 Update README 2023-12-13 11:16:31 +01:00
Tobias Thiele
e9908bea99 Update README 2023-12-13 11:13:36 +01:00
12 changed files with 952 additions and 248 deletions

View file

@ -2,48 +2,64 @@ name: Build
on: on:
push: push:
branches:
- main
tags: tags:
- "*" - "*"
pull_request:
branches:
- main
env: env:
BUILD_NUMBER: ${{ github.tag }}
PROJECT_NAME: "Flashlight" PROJECT_NAME: "Flashlight"
PROJECT_PATH: "./Flashlight/Flashlight.csproj" PROJECT_PATH: "./Flashlight/Flashlight.csproj"
OUTPUT_PATH: "./Flashlight/Flashlight" OUTPUT_PATH: "./Flashlight/Flashlight"
PUBLISH_PATH: "./Flashlight/Flashlight" PUBLISH_PATH: "./Flashlight/Flashlight"
DOTNET_VERSION: "10.0.x"
jobs: jobs:
build: test:
permissions: write-all
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v3 uses: actions/setup-dotnet@v4
with: 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 - name: Restore
run: dotnet restore run: dotnet restore
- name: Build - 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: publish:
if: github.event_name == 'push' if: startsWith(github.ref, 'refs/tags/')
permissions: write-all permissions: write-all
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build needs: build
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v3 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: 7.0.x dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Restore - name: Restore
run: dotnet restore run: dotnet restore
- name: Build - 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 - name: Clean files
run: | run: |
rm -f \ rm -f \
@ -55,11 +71,12 @@ jobs:
type: 'zip' type: 'zip'
filename: '${{ env.PROJECT_NAME }}.zip' filename: '${{ env.PROJECT_NAME }}.zip'
path: ${{ env.PUBLISH_PATH }} path: ${{ env.PUBLISH_PATH }}
- name: GunGameLite - name: Create Release
uses: ncipollo/release-action@v1.12.0 uses: ncipollo/release-action@v1.14.0
with: with:
artifacts: "${{ env.PROJECT_NAME }}.zip" artifacts: "${{ env.PROJECT_NAME }}.zip"
name: "${{ env.BUILD_NUMBER }}" name: "${{ github.ref_name }}"
tag: "${{ env.BUILD_NUMBER }}" tag: "${{ github.ref_name }}"
allowUpdates: true
body: | body: |
Place the plugin in game/csgo/addons/counterstrikesharp/plugins/Flashlight Place the plugin in game/csgo/addons/counterstrikesharp/plugins/Flashlight

6
.gitignore vendored
View file

@ -1,3 +1,5 @@
.idea .idea
Flashlight/bin **/bin/
Flashlight/obj **/obj/
.DS_Store
docs/

View file

@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Flashlight/Flashlight.csproj" />
</ItemGroup>
</Project>

View file

@ -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);
}
}

View file

@ -1,7 +1,9 @@

Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flashlight", "Flashlight\Flashlight.csproj", "{D479E900-33D8-4D58-945B-FB2F79DB5742}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flashlight", "Flashlight\Flashlight.csproj", "{D479E900-33D8-4D58-945B-FB2F79DB5742}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flashlight.Tests", "Flashlight.Tests\Flashlight.Tests.csproj", "{A1B2C3D4-1234-5678-90AB-CDEF12345678}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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}.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.ActiveCfg = Release|Any CPU
{D479E900-33D8-4D58-945B-FB2F79DB5742}.Release|Any CPU.Build.0 = 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 EndGlobalSection
EndGlobal EndGlobal

View file

@ -1,99 +1,119 @@
using System.Drawing; using System.Drawing;
using System.Numerics;
using CounterStrikeSharp.API; using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes;
using CounterStrikeSharp.API.Core.Attributes.Registration; using CounterStrikeSharp.API.Core.Attributes.Registration;
using CounterStrikeSharp.API.Modules.Commands; using CounterStrikeSharp.API.Modules.Commands;
using Vector = CounterStrikeSharp.API.Modules.Utils.Vector; using CounterStrikeSharp.API.Modules.Utils;
using Microsoft.Extensions.Logging;
namespace Flashlight; namespace Flashlight;
public class Flashlight : BasePlugin [MinimumApiVersion(371)]
public class FlashlightPlugin : BasePlugin, IPluginConfig<FlashlightConfig>
{ {
public override string ModuleAuthor => "creazy.eth"; public override string ModuleAuthor => "creazy.eth";
public override string ModuleName => "Flashlight"; public override string ModuleName => "Flashlight";
public override string ModuleDescription => "Flashlight for Counter-Strike 2"; public override string ModuleDescription => "Flashlight for Counter-Strike 2";
public override string ModuleVersion => "0.0.3"; public override string ModuleVersion => "0.1.2";
private static string ModuleDisplayName => "Flashlight"; public FlashlightConfig Config { get; set; } = new();
// TODO: Change crouch-tracking to a more elegant solution private readonly Dictionary<int, PlayerFlashlightState> _playerStates = new();
// TODO: Add config and make light entity values configurable
// TODO: Maybe replace light_omni2 with light_rect or something else
public static Flashlight? Instance { get; private set; } public void OnConfigParsed(FlashlightConfig config)
{
private readonly List<CCSPlayerController> _connectedPlayers = new(); config.Clamp();
private readonly Dictionary<CCSPlayerController, bool> _playerUsingFlashlight = new(); Config = config;
private readonly Dictionary<CCSPlayerController, bool> _playerIsCrouching = new(); }
private readonly Dictionary<CCSPlayerController, bool> _playerCanToggle = new();
private readonly Dictionary<CCSPlayerController, COmniLight> _playerFlashlight = new();
public override void Load(bool hotReload) 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<Listeners.OnTick>(OnTick);
RegisterListener<Listeners.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); EnsureState(player);
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;
}
} }
}); }
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] [GameEventHandler]
public HookResult OnPlayerConnectFull(EventPlayerConnectFull @event, GameEventInfo info) public HookResult OnPlayerConnectFull(EventPlayerConnectFull @event, GameEventInfo info)
{ {
var player = @event.Userid; var player = @event.Userid;
if (player is null || !IsEligiblePlayer(player))
{
return HookResult.Continue;
}
if (!player.IsValid || player.IsBot) return HookResult.Continue; EnsureState(player);
Logger.LogInformation("{Player} connected", player.PlayerName);
_connectedPlayers.Add(player);
_playerUsingFlashlight[player] = false;
_playerIsCrouching[player] = false;
_playerCanToggle[player] = true;
LogHelper.LogToConsole(ConsoleColor.Green, $"{player.PlayerName} connected");
return HookResult.Continue; return HookResult.Continue;
} }
@ -101,20 +121,14 @@ public class Flashlight : BasePlugin
public HookResult OnPlayerDisconnect(EventPlayerDisconnect @event, GameEventInfo info) public HookResult OnPlayerDisconnect(EventPlayerDisconnect @event, GameEventInfo info)
{ {
var player = @event.Userid; var player = @event.Userid;
if (player is null || !player.IsValid)
{
return HookResult.Continue;
}
if (!player.IsValid || player.IsBot) return HookResult.Continue; var slot = player.Slot;
DestroyLight(slot);
_connectedPlayers.Remove(player); _playerStates.Remove(slot);
_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");
return HookResult.Continue; return HookResult.Continue;
} }
@ -122,11 +136,14 @@ public class Flashlight : BasePlugin
public HookResult OnPlayerSpawn(EventPlayerSpawn @event, GameEventInfo info) public HookResult OnPlayerSpawn(EventPlayerSpawn @event, GameEventInfo info)
{ {
var player = @event.Userid; var player = @event.Userid;
if (player is null || !IsEligiblePlayer(player))
{
return HookResult.Continue;
}
if (!player.IsValid || player.IsBot) return HookResult.Continue; var state = EnsureState(player);
state.IsOn = false;
_playerUsingFlashlight[player] = false; DestroyLight(player.Slot);
return HookResult.Continue; return HookResult.Continue;
} }
@ -134,72 +151,218 @@ public class Flashlight : BasePlugin
public HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info) public HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info)
{ {
var player = @event.Userid; var player = @event.Userid;
if (player is null || !IsEligiblePlayer(player))
{
return HookResult.Continue;
}
if (!player.IsValid || player.IsBot) return HookResult.Continue; var state = EnsureState(player);
state.IsOn = false;
_playerUsingFlashlight[player] = false; DestroyLight(player.Slot);
return HookResult.Continue; return HookResult.Continue;
} }
public void ToggleFlashlight(CCSPlayerController player) [GameEventHandler]
public HookResult OnPlayerTeam(EventPlayerTeam @event, GameEventInfo info)
{ {
if (_playerUsingFlashlight[player] == false) var player = @event.Userid;
if (player is null || !IsEligiblePlayer(player))
{ {
if (_playerFlashlight.TryGetValue(player, out var value)) return HookResult.Continue;
{
value.Remove();
_playerFlashlight.Remove(player);
}
return;
} }
var entity = _playerFlashlight.TryGetValue(player, out var flashlight) ? flashlight : Utilities.CreateEntityByName<COmniLight>("light_omni2"); var state = EnsureState(player);
state.IsOn = false;
if (entity == null || !entity.IsValid) DestroyLight(player.Slot);
{ return HookResult.Continue;
LogHelper.LogToConsole("Failed to create entity!");
return;
}
entity.DirectLight = 3;
entity.Teleport(
new Vector(
player.PlayerPawn.Value!.AbsOrigin!.X,
player.PlayerPawn.Value!.AbsOrigin!.Y,
player.PlayerPawn.Value!.AbsOrigin!.Z + (_playerIsCrouching[player] ? 46.03f : 64.03f)
),
player.PlayerPawn.Value!.EyeAngles,
player.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[player] = entity;
} }
[ConsoleCommand("fl_toggle", "Toggles the flashlight")] [ConsoleCommand("css_fl_toggle", "Toggles the flashlight")]
[CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] [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 (!Config.Enabled || caller is null || !IsEligiblePlayer(caller) || !caller.PawnIsAlive)
if (_playerCanToggle[caller] == false) return;
_playerUsingFlashlight[caller] = !_playerUsingFlashlight[caller];
_playerCanToggle[caller] = false;
Instance?.AddTimer(0.25f, () =>
{ {
_playerCanToggle[caller] = true; return;
}
TryToggleFlashlight(caller, EnsureState(caller));
}
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<CBarnLight>("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;
}
/// <summary>
/// Keeps the light glued to the player's eye every tick.
/// </summary>
/// <remarks>
/// The light is deliberately not parented to the pawn. Handing it to the engine via
/// <c>SetParent</c> / <c>SetParentAttachmentMaintainOffset</c> 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.
/// </remarks>
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 };
}
} }

View file

@ -1,13 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.124" /> <PackageReference Include="CounterStrikeSharp.API" Version="1.0.371" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -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;
/// <summary>
/// Which team may use the flashlight: Any, CT, or T.
/// </summary>
[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"
};
}
}

View file

@ -0,0 +1,115 @@
using System.Numerics;
namespace Flashlight;
/// <summary>
/// Position and orientation to apply to the flashlight entity.
/// <paramref name="Angles"/> is a Source QAngle laid out as (pitch, yaw, roll).
/// </summary>
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));
}
/// <summary>
/// Forward vector on the horizontal plane only, ignoring pitch.
/// </summary>
public static Vector3 HorizontalForwardFromYawDegrees(float yaw)
{
var yawRad = yaw * (MathF.PI / 180f);
return new Vector3(MathF.Cos(yawRad), MathF.Sin(yawRad), 0f);
}
/// <summary>
/// World transform for the flashlight given the player's current pawn origin and view angles.
/// </summary>
/// <remarks>
/// 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).
/// </remarks>
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;
}
/// <summary>
/// Returns whether <paramref name="team"/> may use the flashlight.
/// Team values match CS2: 2 = Terrorist, 3 = Counter-Terrorist.
/// </summary>
public static bool IsTeamAllowed(string allowedTeam, byte team)
{
return allowedTeam switch
{
"T" => team == 2,
"CT" => team == 3,
_ => true
};
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

121
README.md
View file

@ -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 using `Use` key (the default key for this is `E`) or `/fl_toggle` in chat which could be bind to a different key. - Toggle with the Use key (`E` by default) or `css_fl_toggle`
- 💀 The flashlight is automatically turned off when the player dies or respawns. - One `light_barn` per player, re-aimed each tick so the beam tracks both pitch and yaw
- 🚫 The flashlight is only available to human players, not bots. - 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. - CounterStrikeSharp **1.0.371+** (API minimum version enforced)
2. 📁 Extract the ZIP file. - .NET **10** runtime as provided by your CounterStrikeSharp install
3. 📂 Place the plugin in the `game/csgo/addons/counterstrikesharp/plugins/Flashlight` directory.
## 💻 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 ## Configuration
- Add config and make light entity values configurable
- Maybe replace light_omni2 with light_rect or something else
## 📃 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) ### 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)