RifleScope v1.0.0

This commit is contained in:
Justin Oros
2026-09-27 13:11:23 -07:00
commit 1a81bdc552
7 changed files with 1193 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
bin/
obj/
dist/
*.zip
+132
View File
@@ -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
+97
View File
@@ -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.
+753
View File
@@ -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<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}");
}
}
}
+37
View File
@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<AssemblyName>RifleScope</AssemblyName>
<RootNamespace>RifleScope</RootNamespace>
<Version>1.0.0</Version>
<NoWarn>CS0436;CS1701;CS1702;MSB3277;MSB3106</NoWarn>
</PropertyGroup>
<PropertyGroup Condition="'$(GameDir)' == ''">
<GameDir>$(SOTF_PATH)</GameDir>
</PropertyGroup>
<Target Name="CheckGameDir" BeforeTargets="BeforeBuild">
<Error Condition="'$(GameDir)' == ''"
Text="GameDir is not set. Run build.ps1, pass -p:GameDir=&quot;path&quot;, or set SOTF_PATH." />
<Error Condition="!Exists('$(GameDir)\_RedLoader\Game\Sons.dll')"
Text="No RedLoader interop assemblies at $(GameDir)\_RedLoader\Game. Install RedLoader and launch the game once." />
</Target>
<ItemGroup>
<Reference Include="$(GameDir)\_RedLoader\Game\*.dll" Private="false" />
<Reference Include="$(GameDir)\_RedLoader\net6\RedLoader.dll" Private="false" />
<Reference Include="$(GameDir)\_RedLoader\net6\SonsSdk.dll" Private="false" />
<Reference Include="$(GameDir)\_RedLoader\net6\Il2CppInterop.Runtime.dll" Private="false" />
<Reference Include="$(GameDir)\_RedLoader\net6\Il2CppInterop.Common.dll" Private="false" />
<Reference Include="$(GameDir)\_RedLoader\net6\0Harmony.dll" Private="false" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="manifest.json" />
</ItemGroup>
</Project>
+159
View File
@@ -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
}
+11
View File
@@ -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"
}