From 1a81bdc552717aa80d2a19c11f699a5f775cc33a Mon Sep 17 00:00:00 2001 From: Justin Oros Date: Sun, 27 Sep 2026 13:11:23 -0700 Subject: [PATCH] RifleScope v1.0.0 --- .gitignore | 4 + Install.ps1 | 132 ++++++++ README.md | 97 ++++++ RifleScope.cs | 753 ++++++++++++++++++++++++++++++++++++++++++++++ RifleScope.csproj | 37 +++ build.ps1 | 159 ++++++++++ manifest.json | 11 + 7 files changed, 1193 insertions(+) create mode 100644 .gitignore create mode 100644 Install.ps1 create mode 100644 README.md create mode 100644 RifleScope.cs create mode 100644 RifleScope.csproj create mode 100644 build.ps1 create mode 100644 manifest.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ce79dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +bin/ +obj/ +dist/ +*.zip diff --git a/Install.ps1 b/Install.ps1 new file mode 100644 index 0000000..684346d --- /dev/null +++ b/Install.ps1 @@ -0,0 +1,132 @@ +[CmdletBinding()] +param( + [string]$GameDir +) + +$ErrorActionPreference = 'Stop' + +function Write-Step { + param([string]$Text) + Write-Host '' + Write-Host $Text -ForegroundColor Cyan +} + +function Find-GameDir { + $steam = (Get-ItemProperty 'HKCU:\Software\Valve\Steam' -ErrorAction SilentlyContinue).SteamPath + if (-not $steam) { return $null } + $libs = @($steam) + $vdf = Join-Path $steam 'steamapps\libraryfolders.vdf' + if (Test-Path $vdf) { + Select-String -Path $vdf -Pattern '"path"\s+"(.+?)"' -AllMatches | + ForEach-Object { $_.Matches } | + ForEach-Object { $libs += $_.Groups[1].Value.Replace('\\','\') } + } + foreach ($lib in $libs) { + $candidate = Join-Path $lib 'steamapps\common\Sons Of The Forest' + if (Test-Path (Join-Path $candidate 'SonsOfTheForest.exe')) { return $candidate } + } + return $null +} + +function Get-ReleaseZip { + param([string]$Repo, [string]$Pattern, [string]$OutFile) + $release = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases/latest" -Headers @{ 'User-Agent' = 'ps' } + $asset = $release.assets | Where-Object { $_.name -match $Pattern } | Select-Object -First 1 + if (-not $asset) { throw "Could not find a download matching $Pattern in the latest $Repo release" } + Write-Host " version $($release.tag_name)" + Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $OutFile -UseBasicParsing + return $asset.name +} + +try { + Write-Host '' + Write-Host '====================================' -ForegroundColor Green + Write-Host ' Rifle Scope installer' -ForegroundColor Green + Write-Host '====================================' -ForegroundColor Green + + if (Get-Process -Name 'SonsOfTheForest' -ErrorAction SilentlyContinue) { + throw 'Sons of the Forest is running. Close the game and run the installer again.' + } + + Write-Step 'Looking for Sons of the Forest' + if (-not $GameDir) { $GameDir = Find-GameDir } + if (-not $GameDir -or -not (Test-Path (Join-Path $GameDir 'SonsOfTheForest.exe'))) { + Write-Host ' Could not find the game automatically.' -ForegroundColor Yellow + Write-Host ' In Steam, right-click Sons Of The Forest, then Manage, then Browse local files.' + Write-Host ' Copy the folder path from the address bar and paste it below.' + $GameDir = (Read-Host ' Game folder').Trim('"') + if (-not (Test-Path (Join-Path $GameDir 'SonsOfTheForest.exe'))) { + throw "SonsOfTheForest.exe was not found in $GameDir" + } + } + Write-Host " found: $GameDir" -ForegroundColor Green + + $needsFirstLaunch = $false + + Write-Step 'Checking for RedLoader' + if (Test-Path (Join-Path $GameDir '_RedLoader\net6\SonsSdk.dll')) { + Write-Host ' already installed' -ForegroundColor Green + } + else { + Write-Host ' not installed, downloading it now' + $rlZip = Join-Path $env:TEMP 'RedLoader.zip' + Get-ReleaseZip -Repo 'ToniMacaroni/RedLoader' -Pattern '^RedLoader.*\.zip$' -OutFile $rlZip | Out-Null + + $rlStage = Join-Path $env:TEMP 'redloader-stage' + if (Test-Path $rlStage) { Remove-Item $rlStage -Recurse -Force } + Expand-Archive -Path $rlZip -DestinationPath $rlStage -Force + Copy-Item -Path (Join-Path $rlStage '*') -Destination $GameDir -Recurse -Force + Remove-Item $rlZip -Force -ErrorAction SilentlyContinue + Remove-Item $rlStage -Recurse -Force -ErrorAction SilentlyContinue + Write-Host ' installed' -ForegroundColor Green + $needsFirstLaunch = $true + } + + Write-Step 'Downloading RifleScope' + $modZip = Join-Path $env:TEMP 'RifleScope.zip' + Get-ReleaseZip -Repo 'JustinOros/sotf-riflescope' -Pattern '^RifleScope\.zip$' -OutFile $modZip | Out-Null + + Write-Step 'Installing RifleScope' + $modsDir = Join-Path $GameDir 'Mods' + New-Item -ItemType Directory -Force -Path $modsDir | Out-Null + Remove-Item (Join-Path $modsDir 'RifleScope.dll') -Force -ErrorAction SilentlyContinue + Remove-Item (Join-Path $modsDir 'RifleScope') -Recurse -Force -ErrorAction SilentlyContinue + Expand-Archive -Path $modZip -DestinationPath $modsDir -Force + Remove-Item $modZip -Force -ErrorAction SilentlyContinue + + if (-not (Test-Path (Join-Path $modsDir 'RifleScope.dll'))) { + throw 'Something went wrong, RifleScope.dll is not in the Mods folder' + } + if (-not (Test-Path (Join-Path $modsDir 'RifleScope\manifest.json'))) { + throw 'Something went wrong, manifest.json is not in the Mods\RifleScope folder' + } + Write-Host ' installed' -ForegroundColor Green + + Write-Host '' + Write-Host '====================================' -ForegroundColor Green + Write-Host ' Done' -ForegroundColor Green + Write-Host '====================================' -ForegroundColor Green + Write-Host '' + + if ($needsFirstLaunch) { + Write-Host 'RedLoader was installed for the first time, so the next time you start' + Write-Host 'the game it will take a few extra minutes to get ready. That is normal.' + Write-Host '' + } + + Write-Host 'Start Sons of the Forest, then:' + Write-Host ' 1. Check that MODS appears on the main menu and lists RifleScope' + Write-Host ' 2. Load into a game, equip the rifle and aim down the scope' + Write-Host ' 3. Press F1 and type scopezoom to see or change the zoom' + Write-Host '' +} +catch { + Write-Host '' + Write-Host "Install failed: $($_.Exception.Message)" -ForegroundColor Red + if ($_.Exception.Message -match 'denied') { + Write-Host 'Try again from PowerShell opened with Run as administrator.' -ForegroundColor Yellow + } + Write-Host '' +} + +Read-Host 'Press Enter to close' | Out-Null diff --git a/README.md b/README.md new file mode 100644 index 0000000..43f43b1 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# Rifle Scope + +A stronger rifle scope for Sons of the Forest. Aiming down the rifle scope zooms +5x further than the vanilla scope and shows a full screen scope view with a +crosshair reticle, mil dots and a rangefinder instead of the red dot. + +## Multiplayer + +Client side only. Nothing gets installed on a dedicated server, and other players +do not need the mod. + +## Installation + +### Easy install + +Close the game, open PowerShell and paste: + +```powershell +irm https://raw.githubusercontent.com/JustinOros/sotf-riflescope/main/Install.ps1 | iex +``` + +It finds your game, installs RedLoader if needed, and installs the latest +RifleScope. Run it again any time to update. + +### Manual install + +#### Step 1: Install RedLoader + +[RedLoader](https://github.com/ToniMacaroni/RedLoader/releases/latest) is the mod +loader for Sons of the Forest. The game cannot load any mod without it, so +install it first. You only have to do this once. + +1. Download `RedLoader.zip` from the + [latest RedLoader release](https://github.com/ToniMacaroni/RedLoader/releases/latest) +2. Extract it into your Sons of the Forest folder, the one containing + `SonsOfTheForest.exe`, usually + `C:\Program Files (x86)\Steam\steamapps\common\Sons Of The Forest` +3. Launch the game once and wait until you reach the main menu. The first launch + takes a few minutes while RedLoader processes the game files +4. Check that `MODS` appears on the main menu, then quit + +#### Step 2: Install RifleScope + +1. Download `RifleScope.zip` from the + [latest release](https://github.com/JustinOros/sotf-riflescope/releases/latest) +2. Extract it into the `Mods` folder inside your game folder +3. You should end up with: + +``` +Mods\RifleScope.dll +Mods\RifleScope\manifest.json +``` + +## Usage + +Equip the rifle and aim. Once zoomed in, the rifle model is hidden and the scope +view is shown. Your shot goes where the crosshair is. + +The red number at the bottom left of the scope is the range in meters to whatever +is under the crosshair. + +The reticle has 10 mil dots in each direction. At 5x each dot is 2 mils, which +covers 20 cm at 100 m. At 10x and higher each dot is 1 mil. Use them to hold over +or to the side at long range. + +Press F1 to open the console, then use these commands: + +| Command | Action | +| --- | --- | +| `scopezoom` | Show the current zoom | +| `scopezoom 5` | Set the zoom as a multiple of the vanilla scope, 1 to 50. The default is 5 | +| `scopetrim` | Show the current vertical trim | +| `scopetrim 50` | Set the vertical trim, -200 to 200. Positive moves hits down, negative moves hits up. The default is 50 | +| `scopewind` | Show the current windage | +| `scopewind 20` | Set the windage, -200 to 200. Positive moves hits right, negative moves hits left. The default is 0 | +| `scopedebug` | Toggle a debug readout above the range | + +The zoom, trim and windage are saved to `UserData\RifleScope.txt` in your game +folder. Setting the zoom to 1 keeps vanilla zoom and the normal scope view, +without the red dot. + +## Building from source + +Requires the .NET 8 SDK and RedLoader installed with its game assemblies +generated. + +```powershell +.\build.ps1 -Install +``` + +Use `-Package` to build `RifleScope.zip` for a release. Pass `-GameDir "path"` if +the game is not found automatically. + +## Troubleshooting + +Check `_RedLoader\Latest.log` in your game folder. RifleScope logs a line when it +loads. diff --git a/RifleScope.cs b/RifleScope.cs new file mode 100644 index 0000000..0786384 --- /dev/null +++ b/RifleScope.cs @@ -0,0 +1,753 @@ +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 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 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()) + { + 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(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(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.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("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}"); + } + } +} diff --git a/RifleScope.csproj b/RifleScope.csproj new file mode 100644 index 0000000..6d7b41c --- /dev/null +++ b/RifleScope.csproj @@ -0,0 +1,37 @@ + + + + net6 + enable + latest + RifleScope + RifleScope + 1.0.0 + CS0436;CS1701;CS1702;MSB3277;MSB3106 + + + + $(SOTF_PATH) + + + + + + + + + + + + + + + + + + + + + diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..56a83e8 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,159 @@ +[CmdletBinding()] +param( + [string]$GameDir, + [switch]$Install, + [switch]$Package +) + +$ErrorActionPreference = 'Stop' + +function Test-Dotnet { + $cmd = Get-Command dotnet -ErrorAction SilentlyContinue + if (-not $cmd) { return $false } + $sdks = & dotnet --list-sdks 2>$null + return ($LASTEXITCODE -eq 0 -and $sdks) +} + +function Update-Path { + $env:Path = [System.Environment]::GetEnvironmentVariable('Path','Machine') + ';' + + [System.Environment]::GetEnvironmentVariable('Path','User') + $dotnetRoot = Join-Path $env:ProgramFiles 'dotnet' + if ((Test-Path $dotnetRoot) -and ($env:Path -notlike "*$dotnetRoot*")) { + $env:Path += ";$dotnetRoot" + } +} + +function Install-Dotnet { + if (Get-Command winget -ErrorAction SilentlyContinue) { + Write-Host 'Installing the .NET 8 SDK with winget' -ForegroundColor Cyan + winget install --id Microsoft.DotNet.SDK.8 --accept-source-agreements --accept-package-agreements --silent + Update-Path + if (Test-Dotnet) { return } + Write-Host ' winget install did not make dotnet available, falling back' -ForegroundColor Yellow + } + + Write-Host 'Downloading the official dotnet-install script' -ForegroundColor Cyan + $script = Join-Path $env:TEMP 'dotnet-install.ps1' + Invoke-WebRequest -Uri 'https://dot.net/v1/dotnet-install.ps1' -OutFile $script -UseBasicParsing + + Write-Host 'Installing the .NET 8 SDK to your user profile' -ForegroundColor Cyan + & $script -Channel 8.0 -InstallDir "$env:LOCALAPPDATA\Microsoft\dotnet" + $userDotnet = "$env:LOCALAPPDATA\Microsoft\dotnet" + $env:Path = "$userDotnet;$env:Path" + [Environment]::SetEnvironmentVariable( + 'Path', + "$userDotnet;" + [Environment]::GetEnvironmentVariable('Path','User'), + 'User') + Remove-Item $script -Force -ErrorAction SilentlyContinue +} + +function Install-RedLoader { + param([string]$Target) + + Write-Host 'Looking up the latest RedLoader release' -ForegroundColor Cyan + $release = Invoke-RestMethod 'https://api.github.com/repos/ToniMacaroni/RedLoader/releases/latest' -Headers @{ 'User-Agent' = 'ps' } + $asset = $release.assets | Where-Object { $_.name -match '^RedLoader.*\.zip$' } | Select-Object -First 1 + if (-not $asset) { throw 'Could not find RedLoader.zip in the latest release' } + Write-Host " $($release.tag_name) / $($asset.name)" + + $zip = Join-Path $env:TEMP $asset.name + Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $zip -UseBasicParsing + + $stage = Join-Path $env:TEMP 'redloader-extract' + if (Test-Path $stage) { Remove-Item $stage -Recurse -Force } + Expand-Archive -Path $zip -DestinationPath $stage -Force + Copy-Item -Path (Join-Path $stage '*') -Destination $Target -Recurse -Force + Remove-Item $zip -Force -ErrorAction SilentlyContinue + Remove-Item $stage -Recurse -Force -ErrorAction SilentlyContinue + + Write-Host ' RedLoader installed' -ForegroundColor Green +} + +function Find-GameDir { + if ($env:SOTF_PATH) { return $env:SOTF_PATH } + $steam = (Get-ItemProperty 'HKCU:\Software\Valve\Steam' -ErrorAction SilentlyContinue).SteamPath + if (-not $steam) { return $null } + $libs = @($steam) + $vdf = Join-Path $steam 'steamapps\libraryfolders.vdf' + if (Test-Path $vdf) { + Select-String -Path $vdf -Pattern '"path"\s+"(.+?)"' -AllMatches | + ForEach-Object { $_.Matches } | + ForEach-Object { $libs += $_.Groups[1].Value.Replace('\\','\') } + } + foreach ($lib in $libs) { + $candidate = Join-Path $lib 'steamapps\common\Sons Of The Forest' + if (Test-Path (Join-Path $candidate 'SonsOfTheForest.exe')) { return $candidate } + } + return $null +} + +if (-not (Test-Dotnet)) { + Write-Host '' + Write-Host 'The .NET 8 SDK is required to build this mod and was not found.' -ForegroundColor Yellow + $answer = Read-Host 'Download and install it now? (y/N)' + if ($answer -ne 'y') { + throw 'Cannot build without the .NET SDK. Get it at https://dotnet.microsoft.com/download' + } + Install-Dotnet + if (-not (Test-Dotnet)) { + throw 'The SDK was installed but dotnet is still not on PATH. Reopen PowerShell and rerun this script.' + } +} +Write-Host ("dotnet SDK: " + (dotnet --version)) -ForegroundColor Green + +if (-not $GameDir) { $GameDir = Find-GameDir } +if (-not $GameDir) { + throw 'Could not find Sons Of The Forest. Pass -GameDir "path" or set SOTF_PATH.' +} +Write-Host "Game directory: $GameDir" -ForegroundColor Cyan + +if (-not (Test-Path (Join-Path $GameDir '_RedLoader\net6\SonsSdk.dll'))) { + Write-Host '' + Write-Host 'RedLoader is not installed in your game folder.' -ForegroundColor Yellow + $answer = Read-Host 'Download and install the latest RedLoader release now? (y/N)' + if ($answer -ne 'y') { + throw 'RedLoader is required. Get it at https://github.com/ToniMacaroni/RedLoader/releases' + } + Install-RedLoader -Target $GameDir +} + +if (-not (Test-Path (Join-Path $GameDir '_RedLoader\Game\Sons.dll'))) { + Write-Host '' + Write-Host 'RedLoader is installed but has not generated its game assemblies yet.' -ForegroundColor Yellow + Write-Host 'Launch Sons of the Forest once and wait for the main menu. The first' + Write-Host 'launch takes a few minutes while it processes the game files.' + Write-Host '' + Write-Host 'Then quit the game and run this script again.' + return +} +Write-Host 'RedLoader ready' -ForegroundColor Green + +dotnet build -c Release --nologo -p:GameDir="$GameDir" +if ($LASTEXITCODE -ne 0) { throw 'Build failed' } + +$dll = Join-Path $PSScriptRoot 'bin\Release\net6\RifleScope.dll' + +if ($Install) { + $modsDir = Join-Path $GameDir 'Mods' + $modDataDir = Join-Path $modsDir 'RifleScope' + New-Item -ItemType Directory -Force -Path $modDataDir | Out-Null + Copy-Item $dll -Destination $modsDir -Force + Copy-Item (Join-Path $PSScriptRoot 'manifest.json') -Destination $modDataDir -Force + Write-Host "Installed to $modsDir" -ForegroundColor Green +} + +if ($Package) { + $stage = Join-Path $PSScriptRoot 'dist' + $zip = Join-Path $PSScriptRoot 'RifleScope.zip' + + if (Test-Path $stage) { Remove-Item $stage -Recurse -Force } + New-Item -ItemType Directory -Force -Path (Join-Path $stage 'RifleScope') | Out-Null + Copy-Item $dll -Destination $stage -Force + Copy-Item (Join-Path $PSScriptRoot 'manifest.json') -Destination (Join-Path $stage 'RifleScope') -Force + + if (Test-Path $zip) { Remove-Item $zip -Force } + Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $zip + Remove-Item $stage -Recurse -Force + + Write-Host "Packaged $zip" -ForegroundColor Green +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..9cbd53c --- /dev/null +++ b/manifest.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://raw.githubusercontent.com/ToniMacaroni/RedLoader/main/MetadataSchema.json", + "id": "RifleScope", + "name": "RifleScope", + "author": "JustinOros", + "version": "1.0.0", + "description": "Stronger rifle scope magnification and a crosshair reticle", + "gameVersion": "1.0.0", + "platform": "Client", + "type": "Mod" +}