RifleScope v1.0.0
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
bin/
|
||||
obj/
|
||||
dist/
|
||||
*.zip
|
||||
+132
@@ -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
|
||||
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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="path", 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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user