Files
ckochandClaude Opus 4.8 59ee13de9d Burglin' Gnomes: finalize fix, docs, and BepInEx install support
Final tuned values baked in as defaults: HudScale=0.8, WorldLabelScale=0.8.

- install.sh gains METHOD=bepinex: installs BepInEx from vendor + the prebuilt
  plugin, with a matching uninstall that leaves BepInEx/ alone (other mods may
  depend on it). Success banner is now method-aware.
- Ship the prebuilt plugin DLL so users don't need a .NET SDK.
- Per-game README documenting why the camera is deliberately untouched
  (orthographic, constant orthographicSize = already Hor+), the two-canvas UI
  problem, tuning, and where the plugin log actually lives.
- Root README lists both games and both fix methods.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:33:56 -04:00

178 lines
8.0 KiB
C#

using BepInEx;
using BepInEx.Configuration;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
namespace BurglinGnomes.UltraWideFix
{
/// <summary>
/// Ultrawide UI fix for Burglin' Gnomes (Unity 6, Mono).
///
/// The camera needs no patching: it is orthographic with a constant
/// orthographicSize (5.0), so a wider display already shows proportionally
/// more world (visible width 17.8 -> 35.6 going 16:9 -> 32:9). That is
/// correct Hor+ behaviour.
///
/// The actual problem is UI scaling:
/// * HUD canvases (ScreenSpaceOverlay + ScaleWithScreenSize) ship
/// matchWidthOrHeight = 0.453, which is width-biased. At 5120px wide that
/// yields scaleFactor 1.948 and a ballooned HUD. Matching HEIGHT (1.0)
/// gives 1.333, and raising referenceResolution.y shrinks it further
/// while giving the layout MORE logical room (cures overlapping text).
/// * World-space label canvases (NamePlate, ...) are NOT affected by
/// CanvasScaler.scaleFactor - CanvasScaler.HandleWorldCanvas() overwrites
/// it with dynamicPixelsPerUnit every frame. Their size comes from the
/// canvas transform's localScale, so that is what we scale.
/// </summary>
[BepInPlugin(PluginGuid, PluginName, PluginVersion)]
public class UltraWideFixPlugin : BaseUnityPlugin
{
public const string PluginGuid = "com.programmingpug.burglingnomes.ultrawidefix";
public const string PluginName = "Burglin' Gnomes UltraWide Fix";
public const string PluginVersion = "1.0.0";
private ConfigEntry<bool> _enabled;
private ConfigEntry<float> _match;
private ConfigEntry<float> _hudScale;
private ConfigEntry<float> _worldScale;
private ConfigEntry<float> _designHeight;
private ConfigEntry<bool> _verbose;
// Baselines captured once per canvas so repeated applies never compound.
private readonly System.Collections.Generic.Dictionary<int, Vector2> _refBaseline
= new System.Collections.Generic.Dictionary<int, Vector2>();
private readonly System.Collections.Generic.Dictionary<int, Vector3> _scaleBaseline
= new System.Collections.Generic.Dictionary<int, Vector3>();
private float _timer;
private void Awake()
{
_enabled = Config.Bind("General", "Enabled", true,
"Master switch for the ultrawide UI fix.");
_match = Config.Bind("HUD", "MatchWidthOrHeight", 1.0f,
new ConfigDescription(
"CanvasScaler match for HUD canvases. 0 = scale by width, 1 = by height. " +
"Keep at 1 for ultrawide; lower values re-introduce width scaling and " +
"cramp the layout vertically.",
new AcceptableValueRange<float>(0f, 1f)));
_hudScale = Config.Bind("HUD", "HudScale", 0.8f,
new ConfigDescription(
"HUD size multiplier. Below 1 shrinks the HUD AND gives the layout more " +
"logical room, which is what cures overlapping text. 1 = stock size.",
new AcceptableValueRange<float>(0.4f, 1.5f)));
_designHeight = Config.Bind("HUD", "DesignHeight", 1080f,
"The game's design reference height. Do not change unless you know why.");
_worldScale = Config.Bind("World", "WorldLabelScale", 0.8f,
new ConfigDescription(
"Size multiplier for in-world labels (nameplates). At 32:9 you see twice " +
"as much world, so more labels are on screen and can collide; lower this " +
"to shrink them. 1 = stock size.",
new AcceptableValueRange<float>(0.3f, 1.5f)));
_verbose = Config.Bind("Debug", "Verbose", false,
"Log every canvas adjustment.");
SceneManager.sceneLoaded += (_, __) => Apply();
Logger.LogInfo($"{PluginName} {PluginVersion} loaded.");
}
private void Start() => Apply();
// Canvases are created lazily (menus, popups), so re-apply periodically.
// We also re-read the config file each tick: BepInEx does NOT pick up
// external edits on its own, so without this you'd have to restart the
// game after every tweak.
private void Update()
{
_timer += Time.unscaledDeltaTime;
if (_timer < 2f) return;
_timer = 0f;
try
{
var stamp = System.IO.File.GetLastWriteTimeUtc(Config.ConfigFilePath);
if (stamp != _configStamp)
{
_configStamp = stamp;
Config.Reload();
Logger.LogInfo($"Config reloaded: HudScale={_hudScale.Value} " +
$"WorldLabelScale={_worldScale.Value} " +
$"Match={_match.Value} Enabled={_enabled.Value}");
// values changed -> re-derive from baselines on this pass
}
}
catch (System.Exception e)
{
Logger.LogWarning($"Config reload failed (check the .cfg syntax): {e.Message}");
}
Apply();
}
private System.DateTime _configStamp;
private void Apply()
{
if (!_enabled.Value) return;
foreach (var cs in FindObjectsOfType<CanvasScaler>())
{
var canvas = cs.GetComponent<Canvas>();
if (canvas == null) continue;
int id = cs.GetInstanceID();
if (canvas.renderMode == RenderMode.WorldSpace)
{
if (Mathf.Approximately(_worldScale.Value, 1f)) continue;
var cur = cs.transform.localScale;
// Games hide world-space UI by zeroing its scale. Never cache a
// zero baseline: if we did, multiplying it would pin the canvas
// to zero forever and the UI could never reappear.
if (!_scaleBaseline.TryGetValue(id, out var base3))
{
if (cur.x <= 0.0001f) continue; // hidden right now; revisit later
base3 = cur;
_scaleBaseline[id] = base3;
}
// Respect the game hiding it: leave zeroed canvases alone.
if (cur.x <= 0.0001f) continue;
var want = base3 * _worldScale.Value;
if (cs.transform.localScale != want)
{
cs.transform.localScale = want;
if (_verbose.Value)
Logger.LogInfo($"World '{cs.name}' localScale {base3} -> {want}");
}
continue;
}
if (cs.uiScaleMode != CanvasScaler.ScaleMode.ScaleWithScreenSize) continue;
if (!_refBaseline.TryGetValue(id, out var baseRef))
{
baseRef = cs.referenceResolution;
_refBaseline[id] = baseRef;
}
float targetH = _designHeight.Value / Mathf.Max(0.01f, _hudScale.Value);
var targetRef = new Vector2(baseRef.x, targetH);
if (!Mathf.Approximately(cs.matchWidthOrHeight, _match.Value))
cs.matchWidthOrHeight = _match.Value;
if (cs.referenceResolution != targetRef)
{
cs.referenceResolution = targetRef;
// expected scale now; Canvas.scaleFactor lags a frame
float exp = Mathf.Pow(Screen.width / targetRef.x, 1f - _match.Value) *
Mathf.Pow(Screen.height / targetRef.y, _match.Value);
Logger.LogInfo($"HUD '{cs.name}' ref {baseRef} -> {targetRef} " +
$"match={_match.Value} => scale={exp:F3}");
}
}
}
}
}