diff --git a/RifleScope.cs b/RifleScope.cs new file mode 100644 index 0000000..f6b7395 --- /dev/null +++ b/RifleScope.cs @@ -0,0 +1,1158 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using HarmonyLib; +using Il2CppInterop.Runtime; +using Il2CppInterop.Runtime.InteropTypes.Arrays; +using RedLoader; +using RedLoader.Utils; +using Sons.FieldOfView; +using Sons.Items.Core; +using Sons.Weapon; +using SonsSdk; +using SonsSdk.Attributes; +using TheForest.Utils; +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +namespace RifleScope; + +public class RifleScope : SonsMod +{ + private const float DefaultZoom = 5f; + private const float MinZoom = 1f; + private const float MaxZoom = 50f; + private const float DefaultTrimCm = 60f; + private const float MaxTrimCm = 200f; + private const float MaxWindCm = 200f; + private const float TrimReferenceRange = 50f; + private const string SuppressorModName = "CompactPistolSuppressorMod"; + private const string SuppressedFireEvent = "event:/SotF Events/player sounds/Weapons/PistolTactical/PistolTacticalFire"; + private const string MuzzleName = "RifleScopeMuzzle"; + private const string RifleFireEvent = "event:/SotF Events/player sounds/Weapons/Rifle/rifle_fire"; + private const string UpgradeParameter = "upgrade"; + private const float SuppressedUpgradeValue = 0.2f; + private const int MilDotCount = 10; + private const float MilDotSize = 0.4f; + private const float MinDotPx = 7f; + private const float MinDotSpacingPx = 14f; + private const float ThinLinePx = 2f; + private const float PostPx = 8f; + private const string RedDotShader = "Sons/RedDot HLSL"; + private const int MaskSize = 1024; + private const float MaxRayDistance = 2000f; + + private static readonly CultureInfo Inv = CultureInfo.InvariantCulture; + private static readonly Dictionary OriginalOffsets = new(); + private static readonly Dictionary HiddenMaterials = new(); + private static readonly HashSet HiddenIds = new(); + + private static float _zoom = DefaultZoom; + private static float _trimCm = DefaultTrimCm; + private static float _windCm; + private static bool _debug; + private static bool _enabled = true; + private static bool _suppressor; + private static string _originalFireEvent; + private static HarmonyLib.Harmony _audioHarmony; + private static bool _loggedParamError; + private static bool _loggedPistolParams; + private static bool _reissuing; + private static readonly List<(Renderer Renderer, int Slot, Material Original)> RedDotSlots = new(); + private static string _configPath; + + private static RifleAnimatorController _rifle; + private static RifleAnimatorController _pendingRifle; + private static ScriptableObject _suppressorMod; + private static float _hipFov; + private static float _vanillaAimFov = 35f; + private static float _targetAimFov; + private static int _heldLayer = -1; + private static Camera _hiddenCam; + private static Transform _projectile; + private static ProjectileTransformConstraint _steeredConstraint; + private static Quaternion _originalShotOffset; + private static bool _steered; + + private static GameObject _overlay; + private static RectTransform _scopeRect; + private static RectTransform _crosshairRect; + private static RectTransform _lineH; + private static RectTransform _lineV; + private static readonly List<(RectTransform Rect, Vector2 Dir)> Posts = new(); + private static float _layoutH = -1f; + private static readonly List<(RectTransform Rect, Vector2 Dir)> MilDots = new(); + private static RectTransform _leftBar; + private static RectTransform _rightBar; + private static TextMeshProUGUI _rangeText; + private static string _lastRange; + private static int _milsPerDot = 1; + + public RifleScope() + { + OnUpdateCallback = OnUpdate; + HarmonyPatchAll = true; + } + + protected override void OnSdkInitialized() + { + _configPath = Path.Combine(LoaderEnvironment.UserDataDirectory, "RifleScope.txt"); + Load(); + RLog.Msg($"RifleScope loaded. Zoom {Fmt(_zoom)}x the vanilla scope. Change it with: scopezoom <{Fmt(MinZoom)}-{Fmt(MaxZoom)}>"); + } + + private void OnUpdate() + { + try + { + Tick(); + } + catch (Exception e) + { + RLog.Error($"RifleScope error: {e.Message}"); + _rifle = null; + SetZoomed(false, null); + } + } + + private static void Tick() + { + if (!_rifle || !_rifle.gameObject.activeInHierarchy) + { + if (_rifle || _hiddenCam) + { + _rifle = null; + SetZoomed(false, null); + } + + var pending = _pendingRifle; + _pendingRifle = null; + if (!pending || !pending.gameObject.activeInHierarchy) + return; + AdoptRifle(pending); + } + + if (!_enabled) + return; + + var manager = _rifle._cameraFovManager; + if (!manager) + { + SetZoomed(false, null); + return; + } + + UpdateOffset(manager); + + var cam = manager._targetCamera; + var zoomed = _rifle.IsAiming && cam && _zoom > 1.01f && cam.fieldOfView < _vanillaAimFov - 0.5f; + SetZoomed(zoomed, cam); + } + + internal static void OnWeaponTick(RangedWeaponController controller) + { + if (!controller || (_rifle && _rifle.Pointer == controller.Pointer)) + return; + var rifle = controller.TryCast(); + if (rifle && rifle.IsLocalPlayer()) + _pendingRifle = rifle; + } + + private static void AdoptRifle(RifleAnimatorController rifle) + { + _rifle = rifle; + _projectile = FindProjectileTransform(rifle); + _originalFireEvent ??= rifle._gunShotAudioEvent; + if (_enabled) + { + if (!_overlay) + CreateOverlay(); + HideRedDot(rifle); + } + if (_suppressor) + SetSuppressor(rifle, true); + } + + private static WeaponMod FindSuppressorMod() + { + if (_suppressorMod) + return _suppressorMod.TryCast(); + foreach (var mod in Resources.FindObjectsOfTypeAll()) + { + if (mod && mod.name == SuppressorModName) + { + _suppressorMod = mod; + return mod; + } + } + return null; + } + + private static Transform FindChild(Transform root, string name) + { + foreach (var t in root.GetComponentsInChildren(true)) + { + if (t.name == name) + return t; + } + return null; + } + + private static bool EnsureMuzzleSlot(RangedWeaponController rifle, WeaponMods mods) + { + var links = mods._modLocations; + for (var i = 0; i < links.Count; i++) + { + if (links[i].Slot == WeaponMod.Slot.Muzzle) + return true; + } + + var anchor = FindChild(rifle.transform, "ProjectileVisualTransform"); + if (!anchor) + return false; + + var muzzle = FindChild(anchor.parent, MuzzleName); + if (!muzzle) + { + var go = new GameObject(MuzzleName); + go.layer = anchor.gameObject.layer; + muzzle = go.transform; + muzzle.SetParent(anchor.parent, false); + muzzle.localPosition = anchor.localPosition; + muzzle.localRotation = Quaternion.identity; + muzzle.localScale = Vector3.one; + } + + var link = new WeaponMods.ModLink(); + link.Slot = WeaponMod.Slot.Muzzle; + link.Location = muzzle; + links.Add(link); + return true; + } + + private static Type FindGameType(string name) + { + var assemblies = AppDomain.CurrentDomain.GetAssemblies(); + foreach (var asm in assemblies) + { + try + { + var t = asm.GetType(name, false) ?? asm.GetType("Il2Cpp." + name, false); + if (t != null) + return t; + } + catch + { + } + } + + foreach (var asm in assemblies) + { + var asmName = asm.GetName().Name ?? string.Empty; + if (asm.IsDynamic || asmName.StartsWith("System", StringComparison.OrdinalIgnoreCase) || + asmName.StartsWith("Unity", StringComparison.OrdinalIgnoreCase) || + asmName.StartsWith("Il2Cppmscorlib", StringComparison.OrdinalIgnoreCase) || + asmName.StartsWith("Microsoft", StringComparison.OrdinalIgnoreCase) || + asmName.StartsWith("mscorlib", StringComparison.OrdinalIgnoreCase)) + continue; + + Type[] types; + try + { + types = asm.GetTypes(); + } + catch (ReflectionTypeLoadException e) + { + types = e.Types.Where(t => t != null).ToArray(); + } + catch + { + continue; + } + + foreach (var t in types) + { + if (t != null && t.Name == name) + return t; + } + } + return null; + } + + private static void InstallAudioPatches() + { + if (_audioHarmony != null) + return; + _audioHarmony = new HarmonyLib.Harmony("RifleScope.SuppressorAudio"); + + var handler = FindGameType("FMOD_AnimationEventHandler"); + var animPrefix = new HarmonyMethod(typeof(RifleScope).GetMethod(nameof(AnimEventPrefix), BindingFlags.NonPublic | BindingFlags.Static)); + var count = 0; + if (handler != null) + { + foreach (var m in handler.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + { + var ps = m.GetParameters(); + if (m.Name != "playFMODEvent" || ps.Length == 0 || ps[0].ParameterType != typeof(string)) + continue; + _audioHarmony.Patch(m, prefix: animPrefix); + count++; + } + } + + var common = FindGameType("FMODCommon"); + var oneshotPrefix = new HarmonyMethod(typeof(RifleScope).GetMethod(nameof(OneshotPrefix), BindingFlags.NonPublic | BindingFlags.Static)); + if (common != null) + { + foreach (var m in common.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.DeclaredOnly)) + { + var ps = m.GetParameters(); + if (m.Name != "PlayOneshotInternal" || ps.Length < 4 || ps[0].ParameterType != typeof(string)) + continue; + _audioHarmony.Patch(m, prefix: oneshotPrefix); + count++; + } + } + + RLog.Msg($"Suppressor audio: patched {count} methods (handler={(handler != null)}, common={(common != null)})"); + } + + private static bool RifleSuppressedNow() => + _suppressor && _rifle && _rifle.gameObject.activeInHierarchy; + + private static bool AnimEventPrefix(Component __instance, object[] __args) + { + try + { + if (!RifleSuppressedNow() || __args == null || __args.Length == 0 || __args[0] as string != RifleFireEvent) + return true; + if (!__instance || __instance.transform.root != _rifle.transform.root) + return true; + return false; + } + catch + { + return true; + } + } + + private static bool OneshotPrefix(MethodBase __originalMethod, object[] __args) + { + if (_reissuing) + return true; + + try + { + if (__args == null || __args.Length < 4 || __args[0] as string != SuppressedFireEvent) + return true; + + var existing = __args[3] as Il2CppReferenceArray; + + if (!RifleSuppressedNow()) + { + if (!_loggedPistolParams && existing != null && existing.Length > 0) + { + _loggedPistolParams = true; + RLog.Msg($"Suppressor audio: pistol params [{string.Join(", ", existing.Select(Describe))}]"); + } + return true; + } + + var oldCount = existing?.Length ?? 0; + for (var i = 0; i < oldCount; i++) + { + if (existing[i] != null && existing[i].ToString() == UpgradeParameter) + return true; + } + + var array = new Il2CppReferenceArray(oldCount + 2); + for (var i = 0; i < oldCount; i++) + array[i] = existing[i]; + array[oldCount] = new Il2CppSystem.Object(IL2CPP.ManagedStringToIl2Cpp(UpgradeParameter)); + array[oldCount + 1] = BoxFloat(SuppressedUpgradeValue); + + var args = (object[])__args.Clone(); + args[3] = array; + _reissuing = true; + try + { + __originalMethod.Invoke(null, args); + } + finally + { + _reissuing = false; + } + return false; + } + catch (Exception e) + { + if (!_loggedParamError) + { + _loggedParamError = true; + RLog.Error($"Suppressor audio: could not play suppressed shot: {(e.InnerException ?? e).Message}"); + } + return true; + } + } + + private static Il2CppSystem.Object BoxFloat(float value) + { + object single = new Il2CppSystem.Single(); + typeof(Il2CppSystem.Single).GetField("m_value", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)?.SetValue(single, value); + return ((Il2CppSystem.Single)single).BoxIl2CppObject(); + } + + private static string Describe(Il2CppSystem.Object o) + { + if (o == null) + return "null"; + try + { + return $"{o.GetIl2CppType().Name}:{o.ToString()}"; + } + catch + { + return "?"; + } + } + + private static void SetSuppressor(RangedWeaponController rifle, bool on) + { + var weapon = rifle._rangedWeapon; + var mods = weapon ? weapon._weaponMods : null; + if (!mods) + { + Say("Suppressor: rifle has no WeaponMods"); + return; + } + + var mod = FindSuppressorMod(); + if (!mod) + { + Say("Suppressor: pistol suppressor mod is not loaded"); + return; + } + + if (on) + { + InstallAudioPatches(); + if (!EnsureMuzzleSlot(rifle, mods)) + { + Say("Suppressor: could not find the rifle muzzle"); + return; + } + + var applied = mods.HasMod(mod.ModItemId) || mods.ApplyMod(mod); + rifle._gunShotAudioEvent = SuppressedFireEvent; + RLog.Msg($"Suppressor on: applied={applied} slot={mod.AttachesToSlot} required={mod.RequiredSlot} item={mod.ModItemId}"); + return; + } + + if (mods.HasMod(mod.ModItemId)) + mods.RemoveMod(mod); + if (_originalFireEvent != null) + rifle._gunShotAudioEvent = _originalFireEvent; + } + + private static Transform FindProjectileTransform(RangedWeaponController rifle) + { + var constraint = rifle._projectileTransformConstraint; + if (constraint) + return constraint.transform; + foreach (var t in rifle.GetComponentsInChildren(true)) + { + if (t.name == "ProjectileTransform") + return t; + } + return null; + } + + private static bool CenterPoint(Camera cam, out Vector3 point) + { + var origin = cam.transform.position; + var dir = cam.transform.forward; + var mask = _rifle ? _rifle._aimHitMask.value : 0; + if (mask == 0) + mask = Physics.DefaultRaycastLayers; + + if (Physics.Raycast(origin, dir, out var hit, MaxRayDistance, mask, QueryTriggerInteraction.Ignore)) + { + point = hit.point; + return true; + } + + point = origin + dir * MaxRayDistance; + return false; + } + + private static float Range(Camera cam) + { + if (!cam || !CenterPoint(cam, out var point)) + return -1f; + return Vector3.Distance(cam.transform.position, point); + } + + private static void SteerShot(Camera cam) + { + var constraint = _rifle._projectileTransformConstraint; + if (!constraint || !_projectile) + return; + + var goal = constraint._target; + if (!goal) + return; + + if (!_steered || _steeredConstraint.Pointer != constraint.Pointer) + { + RestoreShot(); + _steeredConstraint = constraint; + _originalShotOffset = constraint._offset; + _steered = true; + } + + CenterPoint(cam, out var point); + var scale = Vector3.Distance(cam.transform.position, point) / TrimReferenceRange; + point -= cam.transform.up * (_trimCm * 0.01f * scale); + point += cam.transform.right * (_windCm * 0.01f * scale); + var dir = point - _projectile.position; + if (dir.sqrMagnitude < 0.0001f) + return; + + var desired = Quaternion.LookRotation(dir, goal.up); + constraint._offset = Quaternion.Inverse(goal.rotation) * desired; + } + + private static string DebugLine(Camera cam) + { + if (!_projectile || !CenterPoint(cam, out var point)) + return $"trim {Fmt(_trimCm)} cm\nno target"; + + var r = Vector3.Distance(_projectile.position, point); + var barrel = _projectile.position + _projectile.forward * r; + var up = Vector3.Dot(barrel - point, cam.transform.up) * 100f; + var side = Vector3.Dot(barrel - point, cam.transform.right) * 100f; + var eye = Vector3.Dot(_projectile.position - cam.transform.position, cam.transform.up) * 100f; + return $"trim {Fmt(_trimCm)} wind {Fmt(_windCm)} steer {(_steered ? "on" : "off")}\nbarrel up {up:+0.0;-0.0} cm side {side:+0.0;-0.0} cm\nbarrel above eye {eye:+0.0;-0.0} cm"; + } + + private static void RestoreShot() + { + if (_steered && _steeredConstraint) + _steeredConstraint._offset = _originalShotOffset; + _steered = false; + _steeredConstraint = null; + } + + private static void UpdateOffset(FovManager manager) + { + var settings = _rifle._fovChangeSettings; + if (settings == null) + return; + + if (!OriginalOffsets.TryGetValue(settings.Pointer, out var original)) + { + original = settings._FieldOfViewTargetOffset_k__BackingField; + OriginalOffsets[settings.Pointer] = original; + } + + if (!_rifle.IsAiming && manager._activeSource == null) + _hipFov = manager._currentTargetFieldOfView; + if (_hipFov <= 1f) + _hipFov = manager._defaultFov; + + _vanillaAimFov = Mathf.Clamp(_hipFov + original, 1f, 179f); + + if (_rifle.IsAiming) + return; + + var target = 2f * Mathf.Atan(Mathf.Tan(_vanillaAimFov * 0.5f * Mathf.Deg2Rad) / _zoom) * Mathf.Rad2Deg; + _targetAimFov = target; + var offset = target - _hipFov; + if (Mathf.Abs(settings._FieldOfViewTargetOffset_k__BackingField - offset) > 0.001f) + settings._FieldOfViewTargetOffset_k__BackingField = offset; + } + + private static void HideRedDot(RifleAnimatorController rifle) + { + foreach (var renderer in rifle.GetComponentsInChildren(true)) + { + var mats = renderer.sharedMaterials; + var changed = false; + for (var i = 0; i < mats.Length; i++) + { + var mat = mats[i]; + if (!mat || HiddenIds.Contains(mat.GetInstanceID()) || !mat.shader || mat.shader.name != RedDotShader) + continue; + RedDotSlots.Add((renderer, i, mat)); + mats[i] = HiddenFor(mat); + changed = true; + } + if (changed) + renderer.sharedMaterials = mats; + } + } + + private static void RestoreRedDot() + { + foreach (var (renderer, slot, original) in RedDotSlots) + { + if (!renderer || !original) + continue; + var mats = renderer.sharedMaterials; + if (slot >= mats.Length || !mats[slot] || !HiddenIds.Contains(mats[slot].GetInstanceID())) + continue; + mats[slot] = original; + renderer.sharedMaterials = mats; + } + RedDotSlots.Clear(); + } + + private static void Disable() + { + SetZoomed(false, null); + if (_rifle) + { + var settings = _rifle._fovChangeSettings; + if (settings != null && OriginalOffsets.TryGetValue(settings.Pointer, out var original)) + settings._FieldOfViewTargetOffset_k__BackingField = original; + } + RestoreRedDot(); + } + + private static Material HiddenFor(Material original) + { + var id = original.GetInstanceID(); + if (HiddenMaterials.TryGetValue(id, out var cached) && cached) + return cached; + + var hidden = new Material(original) + { + name = original.name + "_Hidden", + hideFlags = HideFlags.HideAndDontSave + }; + hidden.SetColor("_Color", Color.clear); + if (hidden.HasProperty("_EmissionColor")) + hidden.SetColor("_EmissionColor", Color.clear); + + HiddenMaterials[id] = hidden; + HiddenIds.Add(hidden.GetInstanceID()); + return hidden; + } + + private static void SetZoomed(bool zoomed, Camera cam) + { + if (zoomed) + { + if (_heldLayer < 0) + { + _heldLayer = LayerMask.NameToLayer("Held"); + if (_heldLayer < 0) + _heldLayer = 15; + } + + if (_hiddenCam && _hiddenCam != cam) + ShowHeld(); + if (!_hiddenCam) + { + cam.cullingMask &= ~(1 << _heldLayer); + _hiddenCam = cam; + } + + SteerShot(cam); + ShowOverlay(true, Range(cam), _debug ? DebugLine(cam) : null, _targetAimFov > 0f ? _targetAimFov : cam.fieldOfView); + return; + } + + RestoreShot(); + if (_hiddenCam) + ShowHeld(); + _hiddenCam = null; + ShowOverlay(false, -1f, null, 0f); + } + + private static void ShowHeld() + { + if (_hiddenCam) + _hiddenCam.cullingMask |= 1 << _heldLayer; + _hiddenCam = null; + } + + private static void ShowOverlay(bool show, float range, string debug, float fov) + { + if (!show) + { + if (_overlay && _overlay.activeSelf) + _overlay.SetActive(false); + return; + } + + if (!_overlay) + CreateOverlay(); + if (!_overlay.activeSelf) + _overlay.SetActive(true); + + var h = (float)Screen.height; + if (!Mathf.Approximately(h, _layoutH)) + { + _layoutH = h; + LayoutReticle(h); + } + PlaceMilDots(h, fov); + + if (_rangeText) + { + var label = range < 0f ? "--- m" : $"{Mathf.RoundToInt(range)} m"; + if (debug != null) + label = debug + "\n" + label; + if (label != _lastRange) + { + _lastRange = label; + _rangeText.text = label; + } + } + } + + private static void LayoutReticle(float h) + { + var radius = h * 0.495f; + var postStart = radius * 0.55f; + var postLength = h - postStart; + + _scopeRect.sizeDelta = new Vector2(h, h); + _crosshairRect.sizeDelta = new Vector2(h * 2f, h * 2f); + _crosshairRect.anchoredPosition = Vector2.zero; + + _lineH.sizeDelta = new Vector2(h * 2f, ThinLinePx); + _lineV.sizeDelta = new Vector2(ThinLinePx, h * 2f); + + foreach (var (rect, dir) in Posts) + { + if (!rect) + continue; + var center = postStart + postLength * 0.5f; + rect.anchoredPosition = dir * center; + rect.sizeDelta = dir.x != 0f ? new Vector2(postLength, PostPx) : new Vector2(PostPx, postLength); + } + + if (_rangeText) + { + var rt = _rangeText.rectTransform; + rt.anchoredPosition = new Vector2(-0.34f * h, -0.31f * h); + rt.sizeDelta = new Vector2(0.3f * h, 0.2f * h); + _rangeText.fontSize = 0.035f * h; + } + + _leftBar.offsetMax = new Vector2(-h * 0.5f + 1f, 0f); + _rightBar.offsetMin = new Vector2(h * 0.5f - 1f, 0f); + } + + private static void CreateOverlay() + { + _overlay = new GameObject("RifleScopeOverlay"); + UnityEngine.Object.DontDestroyOnLoad(_overlay); + _overlay.hideFlags = HideFlags.HideAndDontSave; + + var canvas = _overlay.AddComponent(); + canvas.renderMode = RenderMode.ScreenSpaceOverlay; + canvas.sortingOrder = -100; + + var crosshairGo = new GameObject("Crosshair"); + crosshairGo.hideFlags = HideFlags.HideAndDontSave; + crosshairGo.transform.SetParent(_overlay.transform, false); + _crosshairRect = crosshairGo.AddComponent(); + Center(_crosshairRect); + + _lineH = CreateImage("LineH", Texture2D.whiteTexture, Color.black, _crosshairRect).rectTransform; + Center(_lineH); + _lineV = CreateImage("LineV", Texture2D.whiteTexture, Color.black, _crosshairRect).rectTransform; + Center(_lineV); + + Posts.Clear(); + foreach (var dir in new[] { Vector2.up, Vector2.down, Vector2.left, Vector2.right }) + { + var post = CreateImage($"Post{dir}", Texture2D.whiteTexture, Color.black, _crosshairRect).rectTransform; + Center(post); + Posts.Add((post, dir)); + } + + CreateMilDots(); + + var scope = CreateImage("Scope", BuildMaskTexture(), Color.white, _overlay.transform); + _scopeRect = scope.rectTransform; + Center(_scopeRect); + + _rangeText = CreateRangeText(scope.transform); + + _leftBar = CreateImage("Left", Texture2D.whiteTexture, Color.black, _overlay.transform).rectTransform; + _leftBar.anchorMin = new Vector2(0f, 0f); + _leftBar.anchorMax = new Vector2(0.5f, 1f); + _leftBar.offsetMin = Vector2.zero; + + _rightBar = CreateImage("Right", Texture2D.whiteTexture, Color.black, _overlay.transform).rectTransform; + _rightBar.anchorMin = new Vector2(0.5f, 0f); + _rightBar.anchorMax = new Vector2(1f, 1f); + _rightBar.offsetMax = Vector2.zero; + + _layoutH = -1f; + _overlay.SetActive(false); + } + + private static void Center(RectTransform rt) + { + rt.anchorMin = new Vector2(0.5f, 0.5f); + rt.anchorMax = new Vector2(0.5f, 0.5f); + rt.pivot = new Vector2(0.5f, 0.5f); + rt.anchoredPosition = Vector2.zero; + } + + private static void CreateMilDots() + { + MilDots.Clear(); + var tex = BuildDotTexture(); + foreach (var dir in new[] { Vector2.up, Vector2.down, Vector2.left, Vector2.right }) + { + for (var k = 1; k <= MilDotCount; k++) + { + var rt = CreateImage($"MilDot{dir}{k}", tex, Color.black, _crosshairRect).rectTransform; + Center(rt); + MilDots.Add((rt, dir * k)); + } + } + } + + private static void PlaceMilDots(float h, float fov) + { + if (fov <= 0f) + return; + + var pxPerMil = h * 0.5f / Mathf.Tan(fov * 0.5f * Mathf.Deg2Rad) * 0.001f; + _milsPerDot = 1; + foreach (var step in new[] { 1, 2, 5, 10 }) + { + _milsPerDot = step; + if (pxPerMil * step >= MinDotSpacingPx) + break; + } + + var spacing = pxPerMil * _milsPerDot; + var size = Math.Max(MinDotPx, MilDotSize * spacing); + var limit = h * 0.495f * 0.95f; + + foreach (var (rect, dir) in MilDots) + { + if (!rect) + continue; + var pos = dir * spacing; + var visible = pos.magnitude <= limit; + if (rect.gameObject.activeSelf != visible) + rect.gameObject.SetActive(visible); + if (!visible) + continue; + rect.anchoredPosition = pos; + rect.sizeDelta = new Vector2(size, size); + } + } + + private static Texture2D BuildDotTexture() + { + const int size = 64; + var pixels = new Color32[size * size]; + var c = (size - 1) * 0.5f; + var radius = size * 0.5f - 1f; + for (var y = 0; y < size; y++) + { + for (var x = 0; x < size; x++) + { + var dx = x - c; + var dy = y - c; + var d = MathF.Sqrt(dx * dx + dy * dy); + var alpha = Math.Clamp(radius - d + 0.5f, 0f, 1f); + pixels[y * size + x] = new Color32(255, 255, 255, (byte)(alpha * 255f + 0.5f)); + } + } + return MakeTexture("RifleScopeMilDot", size, pixels); + } + + private static TextMeshProUGUI CreateRangeText(Transform parent) + { + try + { + var go = new GameObject("Range"); + go.hideFlags = HideFlags.HideAndDontSave; + go.transform.SetParent(parent, false); + var text = go.AddComponent(); + var font = FindFont(); + if (font) + text.font = font; + text.color = new Color(0.9f, 0.08f, 0.08f, 1f); + text.alignment = TextAlignmentOptions.BottomLeft; + text.enableWordWrapping = false; + text.raycastTarget = false; + text.text = string.Empty; + + var rt = text.rectTransform; + rt.anchorMin = new Vector2(0.5f, 0.5f); + rt.anchorMax = new Vector2(0.5f, 0.5f); + rt.pivot = Vector2.zero; + return text; + } + catch (Exception e) + { + RLog.Error($"RifleScope could not create the range readout: {e.Message}"); + return null; + } + } + + private static TMP_FontAsset FindFont() + { + try + { + var font = TMP_Settings.defaultFontAsset; + if (font) + return font; + } + catch + { + } + + foreach (var font in Resources.FindObjectsOfTypeAll()) + { + if (font) + return font; + } + return null; + } + + private static RawImage CreateImage(string name, Texture texture, Color color, Transform parent) + { + var go = new GameObject(name); + go.hideFlags = HideFlags.HideAndDontSave; + go.transform.SetParent(parent, false); + var image = go.AddComponent(); + image.texture = texture; + image.color = color; + image.raycastTarget = false; + return image; + } + + private static Texture2D BuildMaskTexture() + { + var size = MaskSize; + var pixels = new Color32[size * size]; + var c = (size - 1) * 0.5f; + var radius = size * 0.495f; + var fadeStart = radius * 0.88f; + + for (var y = 0; y < size; y++) + { + for (var x = 0; x < size; x++) + { + var dx = x - c; + var dy = y - c; + var d = MathF.Sqrt(dx * dx + dy * dy); + var alpha = Math.Clamp((d - radius) * 0.5f + 0.5f, 0f, 1f); + var t = Math.Clamp((d - fadeStart) / (radius - fadeStart), 0f, 1f); + var fade = t * t * (3f - 2f * t) * 0.55f; + if (fade > alpha) + alpha = fade; + pixels[y * size + x] = new Color32(0, 0, 0, (byte)(alpha * 255f + 0.5f)); + } + } + + return MakeTexture("RifleScopeMask", size, pixels); + } + + private static Texture2D MakeTexture(string name, int size, Color32[] pixels) + { + var tex = new Texture2D(size, size, TextureFormat.RGBA32, false) + { + name = name, + wrapMode = TextureWrapMode.Clamp, + filterMode = FilterMode.Bilinear, + hideFlags = HideFlags.HideAndDontSave + }; + tex.SetPixels32(pixels); + tex.Apply(false, false); + return tex; + } + + [DebugCommand("scope")] + private static void ScopeCommand(string args) + { + args = (args ?? string.Empty).Trim().ToLowerInvariant(); + + if (args == "on" || args == "off") + { + _enabled = args == "on"; + Save(); + if (_enabled) + _rifle = null; + else + Disable(); + Say($"RifleScope {(_enabled ? "on" : "off")}"); + return; + } + + Say($"RifleScope is {(_enabled ? "on" : "off")}. Usage: scope on or scope off"); + } + + [DebugCommand("suppressor")] + private static void SuppressorCommand(string args) + { + args = (args ?? string.Empty).Trim().ToLowerInvariant(); + + if (args != "on" && args != "off") + { + Say($"Rifle suppressor is {(_suppressor ? "on" : "off")}. Usage: suppressor on or suppressor off"); + return; + } + + _suppressor = args == "on"; + Save(); + if (_rifle) + SetSuppressor(_rifle, _suppressor); + Say($"Rifle suppressor {(_suppressor ? "on" : "off")}"); + } + + [DebugCommand("scopezoom")] + private static void ScopeZoomCommand(string args) + { + args = (args ?? string.Empty).Trim(); + + if (args.Length == 0) + { + Say($"Scope zoom is {Fmt(_zoom)}x the vanilla scope. Usage: scopezoom <{Fmt(MinZoom)}-{Fmt(MaxZoom)}>"); + return; + } + + if (!float.TryParse(args, NumberStyles.Float, Inv, out var value) || value < MinZoom || value > MaxZoom) + { + Say($"Usage: scopezoom <{Fmt(MinZoom)}-{Fmt(MaxZoom)}>"); + return; + } + + _zoom = value; + Save(); + Say($"Scope zoom set to {Fmt(_zoom)}x the vanilla scope. Applies the next time you aim."); + } + + [DebugCommand("scopetrim")] + private static void ScopeTrimCommand(string args) + { + args = (args ?? string.Empty).Trim(); + + if (args.Length == 0) + { + Say($"Scope trim is {Fmt(_trimCm)}. Positive moves hits down, negative moves hits up. Usage: scopetrim <-{Fmt(MaxTrimCm)} to {Fmt(MaxTrimCm)}>"); + return; + } + + if (!float.TryParse(args, NumberStyles.Float, Inv, out var value) || value < -MaxTrimCm || value > MaxTrimCm) + { + Say($"Usage: scopetrim <-{Fmt(MaxTrimCm)} to {Fmt(MaxTrimCm)}>"); + return; + } + + _trimCm = value; + Save(); + Say($"Scope trim set to {Fmt(_trimCm)}."); + } + + [DebugCommand("scopewind")] + private static void ScopeWindCommand(string args) + { + args = (args ?? string.Empty).Trim(); + + if (args.Length == 0) + { + Say($"Scope windage is {Fmt(_windCm)}. Positive moves hits right, negative moves hits left. Usage: scopewind <-{Fmt(MaxWindCm)} to {Fmt(MaxWindCm)}>"); + return; + } + + if (!float.TryParse(args, NumberStyles.Float, Inv, out var value) || value < -MaxWindCm || value > MaxWindCm) + { + Say($"Usage: scopewind <-{Fmt(MaxWindCm)} to {Fmt(MaxWindCm)}>"); + return; + } + + _windCm = value; + Save(); + Say($"Scope windage set to {Fmt(_windCm)}."); + } + + [DebugCommand("scopedebug")] + private static void ScopeDebugCommand(string args) + { + _debug = !_debug; + Say($"Scope debug readout {(_debug ? "on" : "off")}"); + } + + private static void Say(string text) + { + RLog.Msg(text); + try + { + SonsTools.ShowMessage(text, 5f); + } + catch + { + } + } + + private static string Fmt(float v) => v.ToString("0.##", Inv); + + private static void Load() + { + try + { + if (!File.Exists(_configPath)) + return; + var parts = File.ReadAllText(_configPath).Split(new[] { ' ', '\r', '\n', '\t' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length > 0 && float.TryParse(parts[0], NumberStyles.Float, Inv, out var zoom) && zoom >= MinZoom && zoom <= MaxZoom) + _zoom = zoom; + if (parts.Length > 1 && float.TryParse(parts[1], NumberStyles.Float, Inv, out var trim) && trim >= -MaxTrimCm && trim <= MaxTrimCm) + _trimCm = trim; + if (parts.Length > 2 && float.TryParse(parts[2], NumberStyles.Float, Inv, out var wind) && wind >= -MaxWindCm && wind <= MaxWindCm) + _windCm = wind; + if (parts.Length > 3) + _enabled = parts[3] != "0"; + if (parts.Length > 4) + _suppressor = parts[4] == "1"; + } + catch (Exception e) + { + RLog.Error($"RifleScope could not read config: {e.Message}"); + } + } + + private static void Save() + { + try + { + File.WriteAllText(_configPath, $"{_zoom.ToString(Inv)} {_trimCm.ToString(Inv)} {_windCm.ToString(Inv)} {(_enabled ? "1" : "0")} {(_suppressor ? "1" : "0")}"); + } + catch (Exception e) + { + RLog.Error($"RifleScope could not write config: {e.Message}"); + } + } +} + +[HarmonyPatch(typeof(RangedWeaponController), nameof(RangedWeaponController.CheckFireInput))] +internal static class CheckFireInputPatch +{ + private static void Postfix(RangedWeaponController __instance) + { + try + { + global::RifleScope.RifleScope.OnWeaponTick(__instance); + } + catch + { + } + } +}