🔦 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>
This commit is contained in:
Tobias Thiele 2026-07-27 08:50:29 +02:00
parent b3bbc2dc9a
commit 422a05c131
5 changed files with 217 additions and 48 deletions

View file

@ -70,6 +70,79 @@ public class FlashlightLogicTests
Assert.Equal(0f, forward.Z, 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] [Fact]
public void ShouldCreateAndDestroyLight_Policies() public void ShouldCreateAndDestroyLight_Policies()
{ {
@ -98,7 +171,6 @@ public class FlashlightLogicTests
ForwardDistance = -10f, ForwardDistance = -10f,
StandEyeOffsetZ = -1f, StandEyeOffsetZ = -1f,
CrouchEyeOffsetZ = -1f, CrouchEyeOffsetZ = -1f,
AttachmentName = " ",
LightCookie = "" LightCookie = ""
}; };
@ -118,7 +190,6 @@ public class FlashlightLogicTests
Assert.Equal(0f, config.ForwardDistance); Assert.Equal(0f, config.ForwardDistance);
Assert.Equal(0f, config.StandEyeOffsetZ); Assert.Equal(0f, config.StandEyeOffsetZ);
Assert.Equal(0f, config.CrouchEyeOffsetZ); Assert.Equal(0f, config.CrouchEyeOffsetZ);
Assert.Equal("axis_of_intent", config.AttachmentName);
Assert.Equal("materials/effects/lightcookies/flashlight.vtex", config.LightCookie); Assert.Equal("materials/effects/lightcookies/flashlight.vtex", config.LightCookie);
} }

View file

@ -16,7 +16,7 @@ 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.1.1"; public override string ModuleVersion => "0.1.2";
public FlashlightConfig Config { get; set; } = new(); public FlashlightConfig Config { get; set; } = new();
@ -57,27 +57,49 @@ public class FlashlightPlugin : BasePlugin, IPluginConfig<FlashlightConfig>
private void OnTick() private void OnTick()
{ {
if (!Config.Enabled || !Config.AllowUseKey) if (!Config.Enabled)
{ {
return; return;
} }
foreach (var player in Utilities.GetPlayers()) foreach (var player in Utilities.GetPlayers())
{ {
if (!IsEligiblePlayer(player) || !player.PawnIsAlive) if (!IsEligiblePlayer(player))
{ {
continue; continue;
} }
var state = EnsureState(player); if (!player.PawnIsAlive)
var usePressed = (player.Buttons & PlayerButtons.Use) != 0;
if (FlashlightLogic.IsUsePressedEdge(usePressed, state.WasUsePressed))
{ {
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<FlashlightConfig>
if (state.IsOn) if (state.IsOn)
{ {
CreateAndParentLight(player, state); CreateLight(player, state);
} }
else else
{ {
@ -205,7 +227,7 @@ public class FlashlightPlugin : BasePlugin, IPluginConfig<FlashlightConfig>
}); });
} }
private void CreateAndParentLight(CCSPlayerController player, PlayerFlashlightState state) private void CreateLight(CCSPlayerController player, PlayerFlashlightState state)
{ {
DestroyLight(player.Slot); DestroyLight(player.Slot);
@ -225,20 +247,6 @@ public class FlashlightPlugin : BasePlugin, IPluginConfig<FlashlightConfig>
return; 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.Enabled = true;
light.Color = Color.FromArgb(255, Config.ColorR, Config.ColorG, Config.ColorB); light.Color = Color.FromArgb(255, Config.ColorR, Config.ColorG, Config.ColorB);
light.ColorTemperature = Config.ColorTemperature; light.ColorTemperature = Config.ColorTemperature;
@ -254,10 +262,7 @@ public class FlashlightPlugin : BasePlugin, IPluginConfig<FlashlightConfig>
light.SizeParams.Y = Config.SizeY; light.SizeParams.Y = Config.SizeY;
light.SizeParams.Z = Config.SizeZ; light.SizeParams.Z = Config.SizeZ;
light.Teleport( ApplyTransform(light, player, pawn);
origin,
new Vector3(angles.X, angles.Y, angles.Z),
null);
using (var keyValues = new CEntityKeyValues()) using (var keyValues = new CEntityKeyValues())
{ {
@ -265,13 +270,69 @@ public class FlashlightPlugin : BasePlugin, IPluginConfig<FlashlightConfig>
light.DispatchSpawn(keyValues); light.DispatchSpawn(keyValues);
} }
light.AcceptInput("SetParent", pawn, light, "!activator");
light.AcceptInput("SetParentAttachmentMaintainOffset", null, null, Config.AttachmentName);
state.Light = light; state.Light = light;
state.IsOn = true; 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) private void DestroyLight(int slot)
{ {
if (!_playerStates.TryGetValue(slot, out var state)) if (!_playerStates.TryGetValue(slot, out var state))

View file

@ -71,9 +71,6 @@ public class FlashlightConfig : BasePluginConfig
[JsonPropertyName("CrouchEyeOffsetZ")] [JsonPropertyName("CrouchEyeOffsetZ")]
public float CrouchEyeOffsetZ { get; set; } = 46f; public float CrouchEyeOffsetZ { get; set; } = 46f;
[JsonPropertyName("AttachmentName")]
public string AttachmentName { get; set; } = "axis_of_intent";
[JsonPropertyName("LightCookie")] [JsonPropertyName("LightCookie")]
public string LightCookie { get; set; } = "materials/effects/lightcookies/flashlight.vtex"; public string LightCookie { get; set; } = "materials/effects/lightcookies/flashlight.vtex";
@ -94,11 +91,6 @@ public class FlashlightConfig : BasePluginConfig
StandEyeOffsetZ = Math.Max(0f, StandEyeOffsetZ); StandEyeOffsetZ = Math.Max(0f, StandEyeOffsetZ);
CrouchEyeOffsetZ = Math.Max(0f, CrouchEyeOffsetZ); CrouchEyeOffsetZ = Math.Max(0f, CrouchEyeOffsetZ);
if (string.IsNullOrWhiteSpace(AttachmentName))
{
AttachmentName = "axis_of_intent";
}
if (string.IsNullOrWhiteSpace(LightCookie)) if (string.IsNullOrWhiteSpace(LightCookie))
{ {
LightCookie = "materials/effects/lightcookies/flashlight.vtex"; LightCookie = "materials/effects/lightcookies/flashlight.vtex";

View file

@ -2,6 +2,12 @@ using System.Numerics;
namespace Flashlight; 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 class FlashlightLogic
{ {
public static bool TryToggle(ref bool isOn, ref bool canToggle) public static bool TryToggle(ref bool isOn, ref bool canToggle)
@ -50,6 +56,39 @@ public static class FlashlightLogic
-MathF.Sin(pitchRad)); -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) public static bool ShouldCreateLight(bool isOn, bool hasValidLight)
{ {
return isOn && !hasValidLight; return isOn && !hasValidLight;

View file

@ -1,12 +1,12 @@
# Flashlight # 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 ## Features
- Toggle with the Use key (`E` by default) or `css_fl_toggle` - 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) - One `light_barn` per player, re-aimed each tick so the beam tracks both pitch and yaw
- Configurable brightness, range, color, shadows, offsets, and attachment - Configurable brightness, range, color, shadows, and offsets
- Optional team restriction (`Any`, `CT`, or `T`) - Optional team restriction (`Any`, `CT`, or `T`)
- Automatically turns off on death, spawn, and team change - Automatically turns off on death, spawn, and team change
- Bots ignored - Bots ignored
@ -49,10 +49,9 @@ On first load, CounterStrikeSharp writes a JSON config for the plugin. Defaults:
| `SoftX` / `SoftY` | `1.0` | Softness | | `SoftX` / `SoftY` | `1.0` | Softness |
| `Skirt` / `SkirtNear` | `0.5` / `1.0` | Skirt falloff | | `Skirt` / `SkirtNear` | `0.5` / `1.0` | Skirt falloff |
| `SizeX` / `SizeY` / `SizeZ` | `45` / `45` / `0.03` | Beam size params | | `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 | | `StandEyeOffsetZ` | `64` | Standing eye height offset |
| `CrouchEyeOffsetZ` | `46` | Crouching eye height offset | | `CrouchEyeOffsetZ` | `46` | Crouching eye height offset |
| `AttachmentName` | `axis_of_intent` | Parent attachment |
| `LightCookie` | `materials/effects/lightcookies/flashlight.vtex` | Flashlight cookie texture | | `LightCookie` | `materials/effects/lightcookies/flashlight.vtex` | Flashlight cookie texture |
## Development ## Development
@ -75,10 +74,17 @@ dotnet build
dotnet test 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 ## 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 ### v0.1.1
- Updated to .NET 10 and CounterStrikeSharp.API 1.0.371 - Updated to .NET 10 and CounterStrikeSharp.API 1.0.371