From 98f13e4a7122fc5ae0b6189c759fb61af54ad0d6 Mon Sep 17 00:00:00 2001 From: Justin Oros Date: Sun, 13 Sep 2026 11:04:45 -0700 Subject: [PATCH] Character Select mod for Sons of the Forest --- .gitignore | 7 ++ CharacterSelect.cs | 94 ++++++++++++++++++++++++ CharacterSelect.csproj | 37 ++++++++++ README.md | 47 ++++++++++++ build.ps1 | 159 +++++++++++++++++++++++++++++++++++++++++ manifest.json | 11 +++ 6 files changed, 355 insertions(+) create mode 100644 .gitignore create mode 100644 CharacterSelect.cs create mode 100644 CharacterSelect.csproj create mode 100644 README.md create mode 100644 build.ps1 create mode 100644 manifest.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ba626a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +dist/ +*.zip +*.user +*.suo +.vs/ diff --git a/CharacterSelect.cs b/CharacterSelect.cs new file mode 100644 index 0000000..66219cd --- /dev/null +++ b/CharacterSelect.cs @@ -0,0 +1,94 @@ +using System; +using System.IO; +using RedLoader; +using RedLoader.Utils; +using Sons.Wearable.Race; +using SonsSdk; +using TheForest.Utils; +using UnityEngine; + +namespace CharacterSelect; + +public class CharacterSelect : SonsMod +{ + private const int RaceCount = 8; + + private static int _index; + private static bool _applied; + private static string _configPath; + + public CharacterSelect() + { + OnUpdateCallback = OnUpdate; + } + + protected override void OnSdkInitialized() + { + _configPath = Path.Combine(LoaderEnvironment.UserDataDirectory, "CharacterSelect.txt"); + Load(); + SdkEvents.OnAfterSpawn.Subscribe(OnSpawned); + RLog.Msg($"CharacterSelect loaded. Saved race: {_index} ({(PlayerRace.Race)_index}). F9 cycles."); + } + + private void OnSpawned() + { + _applied = false; + } + + private void OnUpdate() + { + var system = LocalPlayer.RaceSystem; + if (!system) + return; + + if (!_applied) + { + _applied = true; + Apply(system, false); + return; + } + + if (Input.GetKeyDown(KeyCode.F9)) + { + _index = (_index + 1) % RaceCount; + Apply(system, true); + Save(); + } + } + + private void Apply(PlayerRaceSystem system, bool announce) + { + var race = (PlayerRace.Race)_index; + system.ApplyRace(race); + if (announce) + SonsTools.ShowMessage($"{_index}: {race}"); + RLog.Msg($"Applied race {_index} ({race})"); + } + + private static void Load() + { + try + { + if (!File.Exists(_configPath)) + return; + if (int.TryParse(File.ReadAllText(_configPath).Trim(), out var v) && v >= 0 && v < RaceCount) + _index = v; + } + catch (Exception e) + { + RLog.Error($"CharacterSelect could not read config: {e.Message}"); + } + } + + private static void Save() + { + try + { + File.WriteAllText(_configPath, _index.ToString()); + } + catch (Exception e) + { + RLog.Error($"CharacterSelect could not write config: {e.Message}"); + } + } +} diff --git a/CharacterSelect.csproj b/CharacterSelect.csproj new file mode 100644 index 0000000..e311382 --- /dev/null +++ b/CharacterSelect.csproj @@ -0,0 +1,37 @@ + + + + net6 + enable + latest + CharacterSelect + CharacterSelect + 1.0.0 + CS0436;CS1701;CS1702;MSB3277;MSB3106 + + + + $(SOTF_PATH) + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..56a79cf --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Character Select + +Pick your player appearance in Sons of the Forest and keep it across sessions. + +The game assigns one of eight player appearances at random when you join a server, +and only remembers it if the world gets saved. This mod lets you choose, and +reapplies your choice every time you spawn. + +## Notes + +Client side only. Nothing needs to be installed on a dedicated server. + +Your appearance replicates through normal game networking, so other players see +your choice even if they do not have the mod installed. They will not be able to +change their own appearance without it. + +## Requirements + +RedLoader + +## Installation + +1. Download the latest release +2. Extract into your Sons of the Forest folder so you end up with: + - `Mods/CharacterSelect.dll` + - `Mods/CharacterSelect/manifest.json` + +## Usage + +Press F9 to cycle through the eight appearances. Your choice is saved to +`_RedLoader/UserData/CharacterSelect.txt` and applied automatically on every spawn. + +| Value | Name | +| --- | --- | +| 0 | White | +| 1 | Black | +| 2 | Latin | +| 3 | Asian | +| 4 | BlackA | +| 5 | WhiteA | +| 6 | BlackB | +| 7 | LatinA | + +## Building + +Requires the .NET 8 SDK and a RedLoader install that has been launched once, so +the interop assemblies exist. The script offers to install both if they are missing. diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..1143e63 --- /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\CharacterSelect.dll' + +if ($Install) { + $modsDir = Join-Path $GameDir 'Mods' + $modDataDir = Join-Path $modsDir 'CharacterSelect' + 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 'CharacterSelect.zip' + + if (Test-Path $stage) { Remove-Item $stage -Recurse -Force } + New-Item -ItemType Directory -Force -Path (Join-Path $stage 'CharacterSelect') | Out-Null + Copy-Item $dll -Destination $stage -Force + Copy-Item (Join-Path $PSScriptRoot 'manifest.json') -Destination (Join-Path $stage 'CharacterSelect') -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..4f26750 --- /dev/null +++ b/manifest.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://raw.githubusercontent.com/ToniMacaroni/RedLoader/main/MetadataSchema.json", + "id": "CharacterSelect", + "name": "CharacterSelect", + "author": "JustinOros", + "version": "1.0.0", + "description": "Pick your player appearance and keep it across sessions", + "gameVersion": "1.0.0", + "platform": "Client", + "type": "Mod" +}