🔦 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 b43f9fdea3
5 changed files with 217 additions and 48 deletions

View file

@ -16,7 +16,7 @@ public class FlashlightPlugin : BasePlugin, IPluginConfig<FlashlightConfig>
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<FlashlightConfig>
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<FlashlightConfig>
if (state.IsOn)
{
CreateAndParentLight(player, state);
CreateLight(player, state);
}
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);
@ -225,20 +247,6 @@ public class FlashlightPlugin : BasePlugin, IPluginConfig<FlashlightConfig>
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<FlashlightConfig>
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<FlashlightConfig>
light.DispatchSpawn(keyValues);
}
light.AcceptInput("SetParent", pawn, light, "!activator");
light.AcceptInput("SetParentAttachmentMaintainOffset", null, null, Config.AttachmentName);
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))

View file

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

View file

@ -2,6 +2,12 @@ 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)
@ -50,6 +56,39 @@ public static class FlashlightLogic
-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;