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