Files
sotf-riflescope/RifleScope.cs
T
2026-09-27 13:11:23 -07:00

754 lines
24 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using RedLoader;
using RedLoader.Utils;
using Sons.FieldOfView;
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 = 50f;
private const float MaxTrimCm = 200f;
private const float MaxWindCm = 200f;
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<IntPtr, float> OriginalOffsets = new();
private static readonly Dictionary<int, Material> HiddenMaterials = new();
private static readonly HashSet<int> HiddenIds = new();
private static float _zoom = DefaultZoom;
private static float _trimCm = DefaultTrimCm;
private static float _windCm;
private static bool _debug;
private static string _configPath;
private static RifleAnimatorController _rifle;
private static float _nextSearch;
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;
}
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)
{
_rifle = null;
SetZoomed(false, null);
if (Time.unscaledTime < _nextSearch)
return;
_nextSearch = Time.unscaledTime + 0.5f;
if (!FindRifle())
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);
}
private static bool FindRifle()
{
foreach (var rifle in UnityEngine.Object.FindObjectsOfType<RifleAnimatorController>())
{
if (!rifle || !rifle.gameObject.activeInHierarchy || !rifle.IsLocalPlayer())
continue;
_rifle = rifle;
_projectile = FindProjectileTransform(rifle);
if (!_overlay)
CreateOverlay();
HideRedDot(rifle);
return true;
}
return false;
}
private static Transform FindProjectileTransform(RangedWeaponController rifle)
{
var constraint = rifle._projectileTransformConstraint;
if (constraint)
return constraint.transform;
foreach (var t in rifle.GetComponentsInChildren<Transform>(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);
point -= cam.transform.up * (_trimCm * 0.01f);
point += cam.transform.right * (_windCm * 0.01f);
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<Renderer>(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;
mats[i] = HiddenFor(mat);
changed = true;
}
if (changed)
renderer.sharedMaterials = mats;
}
}
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>();
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<RectTransform>();
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<TextMeshProUGUI>();
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<TMP_FontAsset>())
{
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<RawImage>();
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("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;
}
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)}");
}
catch (Exception e)
{
RLog.Error($"RifleScope could not write config: {e.Message}");
}
}
}