From b3c3542e12541f0c867d5f7d4b605bcde719a8d3 Mon Sep 17 00:00:00 2001 From: Justin Oros Date: Tue, 22 Sep 2026 20:37:40 -0700 Subject: [PATCH] Initial PlayerList mod, hold TAB to show connected players --- .gitignore | 4 + PlayerList.cs | 241 ++++++++++++++++++++++++++++++++++++++++++++++ PlayerList.csproj | 37 +++++++ build.ps1 | 160 ++++++++++++++++++++++++++++++ manifest.json | 11 +++ 5 files changed, 453 insertions(+) create mode 100644 .gitignore create mode 100644 PlayerList.cs create mode 100644 PlayerList.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/PlayerList.cs b/PlayerList.cs new file mode 100644 index 0000000..baec93e --- /dev/null +++ b/PlayerList.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using System.IO; +using RedLoader; +using RedLoader.Utils; +using SonsSdk; +using SonsSdk.Attributes; +using TheForest.Utils; +using UnityEngine; + +namespace PlayerList; + +public class PlayerList : SonsMod +{ + internal static KeyCode ListKey = KeyCode.Tab; + + private static string _configPath; + private static readonly List Names = new(); + private static bool _showing; + private static float _nextRefresh; + private static GUIStyle _titleStyle; + private static GUIStyle _rowStyle; + private static GUIStyle _boxStyle; + private static Texture2D _bg; + + public PlayerList() + { + OnUpdateCallback = OnUpdate; + OnGUICallback = OnGui; + } + + protected override void OnSdkInitialized() + { + _configPath = Path.Combine(LoaderEnvironment.UserDataDirectory, "PlayerList.txt"); + Load(); + Save(); + RLog.Msg($"PlayerList 1.0.1 loaded. Hold {ListKey} to show connected players. Console: playerlist"); + } + + private static bool InMultiplayer => BoltNetwork.isRunning && LocalPlayer.Entity != null; + + private void OnUpdate() + { + _showing = InMultiplayer && Input.GetKey(ListKey); + if (!_showing) + return; + + if (Time.unscaledTime >= _nextRefresh) + { + _nextRefresh = Time.unscaledTime + 0.5f; + Refresh(); + } + } + + private static void Refresh() + { + Names.Clear(); + var seen = new HashSet(); + + var local = LocalPlayer.Entity; + if (local != null) + { + seen.Add(local.Pointer); + Names.Add((GetName(local) ?? "You") + " (you)"); + } + + var tracker = PlayerTracker.Instance; + if (tracker == null || tracker.AllPlayerEntities == null) + return; + + var others = new List(); + foreach (var entity in tracker.AllPlayerEntities) + { + if (entity == null || !seen.Add(entity.Pointer)) + continue; + + var name = GetName(entity); + if (name != null) + others.Add(name); + } + others.Sort(StringComparer.OrdinalIgnoreCase); + Names.AddRange(others); + } + + private static string GetName(BoltEntity entity) + { + try + { + if (entity.TryFindState(out var state) && !string.IsNullOrWhiteSpace(state.name) && !state.name.Equals("Unknown", StringComparison.OrdinalIgnoreCase)) + return state.name; + } + catch (Exception e) + { + RLog.Error($"PlayerList: could not read player name: {e.Message}"); + } + return null; + } + + private static void EnsureStyles() + { + if (_boxStyle != null) + return; + + _bg = new Texture2D(1, 1); + _bg.SetPixel(0, 0, new Color(0f, 0f, 0f, 0.75f)); + _bg.Apply(); + _bg.hideFlags = HideFlags.HideAndDontSave; + + _boxStyle = new GUIStyle(GUI.skin.box); + _boxStyle.normal.background = _bg; + + _titleStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 22, + fontStyle = FontStyle.Bold, + alignment = TextAnchor.MiddleCenter + }; + _titleStyle.normal.textColor = Color.white; + + _rowStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 18, + alignment = TextAnchor.MiddleLeft + }; + _rowStyle.normal.textColor = Color.white; + } + + private void OnGui() + { + if (!_showing) + return; + + EnsureStyles(); + + const float width = 420f; + const float rowHeight = 28f; + const float titleHeight = 40f; + const float pad = 12f; + + var height = titleHeight + Names.Count * rowHeight + pad * 2; + var x = (Screen.width - width) / 2f; + var y = Screen.height * 0.15f; + + GUI.Box(new Rect(x, y, width, height), GUIContent.none, _boxStyle); + GUI.Label(new Rect(x, y + pad, width, titleHeight), $"Players ({Names.Count})", _titleStyle); + + for (var i = 0; i < Names.Count; i++) + { + var rowY = y + pad + titleHeight + i * rowHeight; + GUI.Label(new Rect(x + pad * 2, rowY, width - pad * 4, rowHeight), Names[i], _rowStyle); + } + } + + [DebugCommand("playerlist")] + private void PlayerListCommand(string args) + { + var parts = (args ?? "").Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length == 2 && parts[0].Equals("key", StringComparison.OrdinalIgnoreCase)) + { + if (Enum.TryParse(parts[1], true, out KeyCode key)) + { + ListKey = key; + Save(); + SonsTools.ShowMessage($"PlayerList key: {ListKey}"); + } + else + { + SonsTools.ShowMessage($"Unknown key: {parts[1]}"); + } + return; + } + + if (!InMultiplayer) + { + SonsTools.ShowMessage("Not in a multiplayer game"); + return; + } + + Refresh(); + DumpEntities(); + RLog.Msg($"PlayerList: {Names.Count} player(s)"); + foreach (var name in Names) + RLog.Msg($" {name}"); + SonsTools.ShowMessage($"Players ({Names.Count}): {string.Join(", ", Names)}"); + } + + private static void DumpEntities() + { + var tracker = PlayerTracker.Instance; + if (tracker == null || tracker.AllPlayerEntities == null) + { + RLog.Msg("PlayerList: no PlayerTracker"); + return; + } + + RLog.Msg($"PlayerList: {tracker.AllPlayerEntities.Count} tracked entities, local {LocalPlayer.Entity?.Pointer}"); + foreach (var entity in tracker.AllPlayerEntities) + { + if (entity == null) + { + RLog.Msg(" null entity"); + continue; + } + + var raw = entity.TryFindState(out var state) ? state.name : "(no state)"; + RLog.Msg($" ptr={entity.Pointer} go={entity.gameObject.name} name='{raw}' owner={entity.isOwner} attached={entity.isAttached} source={(entity.source == null ? "none" : entity.source.ToString())}"); + } + } + + private static void Load() + { + if (!File.Exists(_configPath)) + return; + + foreach (var line in File.ReadAllLines(_configPath)) + { + var kv = line.Split('=', 2); + if (kv.Length != 2) + continue; + + var k = kv[0].Trim().ToLowerInvariant(); + var v = kv[1].Trim(); + + if (k == "key" && Enum.TryParse(v, true, out KeyCode key)) + ListKey = key; + } + } + + private static void Save() + { + try + { + File.WriteAllText(_configPath, $"key={ListKey}\n"); + } + catch (Exception e) + { + RLog.Error($"PlayerList: could not save config: {e.Message}"); + } + } +} \ No newline at end of file diff --git a/PlayerList.csproj b/PlayerList.csproj new file mode 100644 index 0000000..0d43bc0 --- /dev/null +++ b/PlayerList.csproj @@ -0,0 +1,37 @@ + + + + net6 + enable + latest + PlayerList + PlayerList + 1.0.0 + CS0436;CS1701;CS1702;MSB3277;MSB3106 + + + + $(SOTF_PATH) + + + + + + + + + + + + + + + + + + + + + diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..4d47cf7 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,160 @@ +[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\PlayerList.dll' + +if ($Install) { + $modsDir = Join-Path $GameDir 'Mods' + $modDataDir = Join-Path $modsDir 'PlayerList' + 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 'PlayerList.zip' + + if (Test-Path $stage) { Remove-Item $stage -Recurse -Force } + New-Item -ItemType Directory -Force -Path (Join-Path $stage 'PlayerList') | Out-Null + Copy-Item $dll -Destination $stage -Force + Copy-Item (Join-Path $PSScriptRoot 'manifest.json') -Destination (Join-Path $stage 'PlayerList') -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..3de6600 --- /dev/null +++ b/manifest.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://raw.githubusercontent.com/ToniMacaroni/RedLoader/main/MetadataSchema.json", + "id": "PlayerList", + "name": "PlayerList", + "author": "JustinOros", + "version": "1.0.0", + "description": "Hold TAB to see the players connected to the server", + "gameVersion": "1.0.0", + "platform": "Client", + "type": "Mod" +}