Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 244 additions & 0 deletions Source/BuilderCamera/BuilderCamera.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using HarmonyLib;
using UnityEngine;

namespace PraetorisClient.BuilderCameraFeature
{
internal static partial class BuilderCamera
{
private static BuilderWard? _ward;
private static bool _active;
private static Vector3 _position;
private static Vector3 _bodyPosition;
private static Quaternion _rotation;
private static float _health;
private static GUIStyle? _statusStyle;
private static int CollisionMask => LayerMask.GetMask("Default", "static_solid", "Default_small", "piece", "terrain", "vehicle");
internal static bool Active => _active;
internal static string Status() => $"Build camera: active={_active}, equipped={(Player.m_localPlayer && Equipped(Player.m_localPlayer))}, camera={_position}, body={(Player.m_localPlayer ? Player.m_localPlayer.transform.position.ToString() : "none")}, wardLimit={WardRange.Value}, bodyLimit={BodyRange.Value}";

internal static void DrawStatus()
{
if (!_active || !_ward || Hud.IsUserHidden()) return;
_statusStyle ??= new GUIStyle(GUI.skin.box) { alignment = TextAnchor.MiddleCenter, fontSize = 16 };
GUI.Box(new Rect(Screen.width / 2f - 240f, 45f, 480f, 58f),
$"BUILD CAMERA\n{ToggleKey.Value}: exit | Space / Left Ctrl: up / down", _statusStyle);
}

private static bool UiOpen => Console.IsVisible() || Menu.IsVisible() || InventoryGui.IsVisible() || StoreGui.IsVisible()
|| Minimap.IsOpen() || (Chat.instance && Chat.instance.HasFocus()) || Hud.IsPieceSelectionVisible() || Hud.InRadial();

private static bool Equipped(Player player)
{
ItemDrop.ItemData tool = player.RightItem;
bool beltEquipped = player.GetInventory().GetAllItems().Exists(item => item.m_equipped && item.m_dropPrefab && item.m_dropPrefab.name == BeltPrefab);
return beltEquipped
&& tool != null && tool.m_shared.m_buildPieces && player.InPlaceMode();
}

internal static void Update()
{
Player player = Player.m_localPlayer;
if (!player || !GameCamera.instance) { Stop(null); return; }
if (_active && (!Equipped(player) || player.IsDead() || player.IsTeleporting() || player.InCutscene()
|| player.IsAttached() || !_ward || !PrivateArea.CheckAccess(_ward.transform.position, flash: false)
|| Vector3.Distance(player.transform.position, _bodyPosition) > 0.5f || player.GetHealth() < _health))
{
Stop("Build camera ended: equipment, body position, health, or ward changed.");
return;
}
_health = player.GetHealth();
if (ToggleKey.Value.IsDown() && !UiOpen)
{
if (_active) { Stop("Build camera ended."); return; }
Start(player);
}
if (!_active) return;
if (!WithinLimits(_position))
{
Stop("Build camera ended: ward unavailable or distance limit reached.");
return;
}
}

private static void Start(Player player)
{
if (player.IsEncumbered())
{
player.Message(MessageHud.MessageType.Center, "Cannot enter build camera while encumbered.");
return;
}
if (!Equipped(player) || player.IsDead() || player.IsTeleporting() || player.IsAttached() || player.InCutscene() || GameCamera.InFreeFly())
{
player.Message(MessageHud.MessageType.Center, "Equip a Builder Belt and hold a build tool to use the build camera.");
return;
}
_ward = null;
float nearest = WardRange.Value;
foreach (BuilderWard ward in BuilderWard.Instances)
{
float distance = Vector3.Distance(ward.transform.position, player.transform.position);
if (distance <= nearest && PrivateArea.CheckAccess(ward.transform.position, flash: false)) { _ward = ward; nearest = distance; }
}
if (!_ward) { player.Message(MessageHud.MessageType.Center, "A Builder's Ward is required nearby."); return; }
_position = GameCamera.instance.transform.position;
_rotation = GameCamera.instance.transform.rotation;
_bodyPosition = player.transform.position;
_health = player.GetHealth();
if (!WithinLimits(_position) || Physics.CheckSphere(_position, 0.2f, CollisionMask, QueryTriggerInteraction.Ignore))
{
_ward = null;
player.Message(MessageHud.MessageType.Center, "Move the camera into clear space within the ward's range.");
return;
}
player.m_autoRun = false;
player.SetMoveDir(Vector3.zero);
_active = true;
player.Message(MessageHud.MessageType.Center, "Build camera active. Your body remains vulnerable.");
}

internal static void Stop(string? reason)
{
bool wasRunning = _active;
_active = false;
_ward = null;
if (wasRunning && reason != null && Player.m_localPlayer) Player.m_localPlayer.Message(MessageHud.MessageType.Center, reason);
}

private static bool WithinLimits(Vector3 point) => _ward && Player.m_localPlayer
&& Vector3.Distance(point, _ward.transform.position) <= WardRange.Value
&& Vector3.Distance(point, Player.m_localPlayer.transform.position) <= BodyRange.Value;

private static void MoveCamera(GameCamera camera, float dt)
{
if (Physics.CheckSphere(_position, 0.18f, CollisionMask, QueryTriggerInteraction.Ignore)) { Stop("Build camera ended: camera space is blocked."); return; }
if (!UiOpen)
{
Vector2 mouse = ZInput.GetMouseDelta();
Vector3 angles = _rotation.eulerAngles;
float pitch = angles.x > 180f ? angles.x - 360f : angles.x;
_rotation = Quaternion.Euler(Mathf.Clamp(pitch - mouse.y * 1.5f, -85f, 85f), angles.y + mouse.x * 1.5f, 0f);
Vector3 input = new Vector3((ZInput.GetKey(KeyCode.D) ? 1 : 0) - (ZInput.GetKey(KeyCode.A) ? 1 : 0),
(ZInput.GetKey(KeyCode.Space) ? 1 : 0) - (ZInput.GetKey(KeyCode.LeftControl) ? 1 : 0),
(ZInput.GetKey(KeyCode.W) ? 1 : 0) - (ZInput.GetKey(KeyCode.S) ? 1 : 0));
Vector3 delta = (_rotation * new Vector3(input.x, 0f, input.z) + Vector3.up * input.y).normalized
* (ZInput.GetKey(KeyCode.LeftShift) ? 10f : 5f) * Mathf.Min(dt, 0.1f);
if (delta.sqrMagnitude > 0f)
{
if (Physics.SphereCast(_position, 0.2f, delta.normalized, out RaycastHit hit, delta.magnitude + 0.02f, CollisionMask, QueryTriggerInteraction.Ignore))
delta = delta.normalized * Mathf.Max(0f, hit.distance - 0.02f);
Vector3 candidate = _position + delta;
if (WithinLimits(candidate) && !Physics.CheckSphere(candidate, 0.18f, CollisionMask, QueryTriggerInteraction.Ignore)) _position = candidate;
}
}
camera.transform.SetPositionAndRotation(_position, _rotation);
}

// Change only the distance checks used by building. Normal placement, cost, station,
// protected-area, repair and removal checks still run on the real player.
private static float BuildDistance(Vector3 eye, Vector3 target)
{
if (!_active) return Vector3.Distance(eye, target);
Player player = Player.m_localPlayer;
if (!player || !Equipped(player)) return float.MaxValue;
return WithinLimits(target) ? Vector3.Distance(_position, target) : float.MaxValue;
}

// RemovePiece passes the ray hit first; placement and hover pass the eye first.
private static float RemoveDistance(Vector3 target, Vector3 eye) => BuildDistance(eye, target);

[HarmonyPatch(typeof(GameCamera), "UpdateCamera")]
private static class CameraUpdate
{
private static bool Prefix(GameCamera __instance, float dt)
{
if (!_active) return true;
MoveCamera(__instance, dt);
return !_active;
}
}

[HarmonyPatch(typeof(Player), nameof(Player.SetControls))]
private static class BodyControls
{
private static void Prefix(Player __instance, ref Vector3 movedir, ref bool attack, ref bool attackHold,
ref bool secondaryAttack, ref bool secondaryAttackHold, ref bool block, ref bool blockHold,
ref bool jump, ref bool crouch, ref bool run, ref bool autoRun, ref bool dodge)
{
if (!_active || __instance != Player.m_localPlayer) return;
movedir = Vector3.zero;
attack = attackHold = secondaryAttack = secondaryAttackHold = block = blockHold = jump = crouch = run = autoRun = dodge = false;
__instance.m_autoRun = false;
__instance.SetMoveDir(Vector3.zero);
}
}

[HarmonyPatch]
private static class BuildReach
{
private static IEnumerable<MethodBase> TargetMethods()
{
yield return AccessTools.Method(typeof(Player), "PieceRayTest");
yield return AccessTools.Method(typeof(Player), "RemovePiece");
yield return AccessTools.Method(typeof(Player), "UpdateWearNTearHover");
}
private static IEnumerable<CodeInstruction> Transpiler(IEnumerable<CodeInstruction> instructions, MethodBase __originalMethod)
{
MethodInfo distance = AccessTools.Method(typeof(Vector3), nameof(Vector3.Distance));
List<CodeInstruction> codes = new List<CodeInstruction>(instructions);
// These game methods each contain exactly one distance call: eye-to-ray-hit
// build reach. Reject changed IL rather than patching unrelated distance checks.
int reachIndex = -1;
for (int index = 0; index < codes.Count; index++)
{
if (!codes[index].Calls(distance)) continue;
if (reachIndex >= 0)
throw new InvalidOperationException($"Builder camera: ambiguous build reach in {__originalMethod.Name}.");
reachIndex = index;
}
if (reachIndex < 0)
throw new InvalidOperationException($"Builder camera: missing build reach in {__originalMethod.Name}.");
string helper = __originalMethod.Name == "RemovePiece" ? nameof(RemoveDistance) : nameof(BuildDistance);
codes[reachIndex].operand = AccessTools.Method(typeof(BuilderCamera), helper);
return codes;
}
}

[HarmonyPatch(typeof(Player), "Interact")]
private static class BlockInteraction { private static bool Prefix(Player __instance) => !_active || __instance != Player.m_localPlayer; }

[HarmonyPatch(typeof(Player), "AutoPickup")]
private static class BlockPickup { private static bool Prefix(Player __instance) => !_active || __instance != Player.m_localPlayer; }

[HarmonyPatch(typeof(Humanoid), nameof(Humanoid.UseItem))]
private static class EndBeforeItemUse
{
private static void Prefix(Humanoid __instance)
{
if (_active && __instance == Player.m_localPlayer) Stop("Build camera ended: using an item.");
}
}

[HarmonyPatch(typeof(Humanoid), nameof(Humanoid.StartAttack))]
private static class BlockCombat
{
private static bool Prefix(Humanoid __instance, ref bool __result)
{
if (!_active || __instance != Player.m_localPlayer) return true;
__result = false;
return false;
}
}

[HarmonyPatch(typeof(Character), nameof(Character.ApplyDamage))]
private static class EndOnDamage
{
private static void Prefix(Character __instance, HitData hit)
{
if (__instance == Player.m_localPlayer && hit.GetTotalDamage() > 0f) Stop("Build camera ended: you took damage.");
}
}
}
}
100 changes: 100 additions & 0 deletions Source/BuilderCamera/BuilderCameraContent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using BepInEx.Configuration;
using HarmonyLib;
using Jotunn.Configs;
using Jotunn.Entities;
using Jotunn.Managers;
using Jotunn.Utils;
using UnityEngine;

namespace PraetorisClient.BuilderCameraFeature
{
internal static partial class BuilderCamera
{
internal const string BeltPrefab = "PraetorisBuilderBelt";
internal const string WardPrefab = "PraetorisBuilderWard";
internal static ConfigEntry<int> BeltPrice = null!;
internal static ConfigEntry<string> TraderPrefab = null!;
internal static ConfigEntry<float> WardRange = null!;
internal static ConfigEntry<float> BodyRange = null!;
internal static ConfigEntry<KeyboardShortcut> ToggleKey = null!;

internal static void Initialize(ConfigFile config)
{
BeltPrice = Bind(config, "BeltPrice", 1500, "Coins charged by the trader.", new AcceptableValueRange<int>(1, 100000));
TraderPrefab = Bind(config, "TraderPrefab", "Hildir", "Trader prefab that sells the Builder Belt (Hildir, Haldor, or BogWitch).");
WardRange = Bind(config, "WardRange", 30f, "Maximum camera and build target distance from the ward, in meters.", new AcceptableValueRange<float>(2f, 100f));
BodyRange = Bind(config, "BodyRange", 30f, "Maximum camera and build target distance from the player's body, in meters.", new AcceptableValueRange<float>(2f, 100f));
ToggleKey = config.Bind("BuilderCamera", "ToggleKey", new KeyboardShortcut(KeyCode.B), new ConfigDescription(
"Local client hotkey to toggle build camera. Equip the Builder Belt and hold a build tool near a Builder's Ward. WASD moves; Space rises; Left Ctrl lowers; Shift moves faster.",
null, new ConfigurationManagerAttributes { IsAdminOnly = false }));
PrefabManager.OnVanillaPrefabsAvailable += RegisterContent;
new Terminal.ConsoleCommand("buildercamera", "Toggle build camera, or inspect it with: buildercamera status", args =>
{
if (args.Length > 1 && args[1] == "status") args.Context.AddString(Status());
else if (_active) Stop("Build camera ended.");
else if (Player.m_localPlayer && GameCamera.instance) Start(Player.m_localPlayer);
});
}

private static ConfigEntry<T> Bind<T>(ConfigFile config, string key, T value, string description, AcceptableValueBase? range = null)
{
return config.Bind("BuilderCamera", key, value, new ConfigDescription(description, range, new ConfigurationManagerAttributes { IsAdminOnly = true }));
}

internal static void Shutdown()
{
Stop(null);
PrefabManager.OnVanillaPrefabsAvailable -= RegisterContent;
}

private static void RegisterContent()
{
CustomItem belt = new CustomItem(BeltPrefab, "BeltStrength");
belt.ItemDrop.m_itemData.m_shared.m_name = "Builder Belt";
belt.ItemDrop.m_itemData.m_shared.m_description = "Equip this belt and hold a build tool near a Builder's Ward to use the build camera. Your body remains vulnerable. Default key: B.";
belt.ItemDrop.m_itemData.m_shared.m_equipStatusEffect = null;
belt.ItemDrop.m_itemData.m_shared.m_setStatusEffect = null;
belt.ItemDrop.m_itemData.m_shared.m_setName = "";
belt.ItemDrop.m_itemData.m_shared.m_maxQuality = 1;
ItemManager.Instance.AddItem(belt);

CustomPiece ward = new CustomPiece(WardPrefab, "guard_stone", new PieceConfig
{
Name = "Builder's Ward",
Description = "Enables unlimited use of a Builder Belt camera nearby. Does not protect your body or grant building permission.",
PieceTable = PieceTables.Hammer,
Category = PieceCategories.Misc,
Requirements = new[] { new RequirementConfig("Wood", 10, recover: true), new RequirementConfig("Stone", 10, recover: true), new RequirementConfig("GreydwarfEye", 5, recover: true) }
});
PrivateArea area = ward.PiecePrefab.GetComponent<PrivateArea>();
if (area.m_areaMarker) area.m_areaMarker.gameObject.SetActive(false);
if (area.m_enabledEffect) area.m_enabledEffect.SetActive(false);
UnityEngine.Object.DestroyImmediate(area);
foreach (ItemStand stand in ward.PiecePrefab.GetComponentsInChildren<ItemStand>(true)) UnityEngine.Object.DestroyImmediate(stand);
foreach (EffectArea effect in ward.PiecePrefab.GetComponentsInChildren<EffectArea>(true)) UnityEngine.Object.DestroyImmediate(effect);
ward.PiecePrefab.AddComponent<BuilderWard>();
WardBuildIcon.Apply(ward.PiecePrefab, "GreydwarfEye");
PieceManager.Instance.AddPiece(ward);
PrefabManager.OnVanillaPrefabsAvailable -= RegisterContent;
}

[HarmonyPatch(typeof(Trader), nameof(Trader.GetAvailableItems))]
private static class TraderItems
{
private static void Postfix(Trader __instance, List<Trader.TradeItem> __result)
{
if (!string.Equals(Utils.GetPrefabName(__instance.gameObject), TraderPrefab.Value, StringComparison.OrdinalIgnoreCase)) return;
GameObject prefab = ObjectDB.instance.GetItemPrefab(BeltPrefab);
if (prefab && !__result.Exists(item => item.m_prefab && item.m_prefab.name == BeltPrefab))
__result.Add(new Trader.TradeItem
{
m_prefab = prefab.GetComponent<ItemDrop>(), m_price = BeltPrice.Value, m_stack = 1,
m_buyPlayerEffects = new EffectList(), m_tooltip = "", m_name = "Builder Belt",
m_requiredGlobalKey = "", m_buyKey = "", m_incrementKey = ""
});
}
}
}
}
22 changes: 22 additions & 0 deletions Source/BuilderCamera/BuilderWard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Collections.Generic;
using UnityEngine;

namespace PraetorisClient.BuilderCameraFeature
{
public sealed class BuilderWard : MonoBehaviour, Hoverable
{
internal static readonly List<BuilderWard> Instances = new List<BuilderWard>();

private void Awake()
{
ZNetView view = GetComponent<ZNetView>();
if (view && view.IsValid()) Instances.Add(this);
}

private void OnDestroy() { Instances.Remove(this); }

public string GetHoverName() => "Builder's Ward";
public float GetHoverOffset() => 0f;
public string GetHoverText() => $"Builder's Ward\nEquip Builder Belt + build tool; {BuilderCamera.ToggleKey.Value} toggles camera";
}
}
Loading
Loading