diff --git a/Source/BuilderCamera/BuilderCamera.cs b/Source/BuilderCamera/BuilderCamera.cs new file mode 100644 index 0000000..6aea790 --- /dev/null +++ b/Source/BuilderCamera/BuilderCamera.cs @@ -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 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 Transpiler(IEnumerable instructions, MethodBase __originalMethod) + { + MethodInfo distance = AccessTools.Method(typeof(Vector3), nameof(Vector3.Distance)); + List codes = new List(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."); + } + } + } +} diff --git a/Source/BuilderCamera/BuilderCameraContent.cs b/Source/BuilderCamera/BuilderCameraContent.cs new file mode 100644 index 0000000..6654814 --- /dev/null +++ b/Source/BuilderCamera/BuilderCameraContent.cs @@ -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 BeltPrice = null!; + internal static ConfigEntry TraderPrefab = null!; + internal static ConfigEntry WardRange = null!; + internal static ConfigEntry BodyRange = null!; + internal static ConfigEntry ToggleKey = null!; + + internal static void Initialize(ConfigFile config) + { + BeltPrice = Bind(config, "BeltPrice", 1500, "Coins charged by the trader.", new AcceptableValueRange(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(2f, 100f)); + BodyRange = Bind(config, "BodyRange", 30f, "Maximum camera and build target distance from the player's body, in meters.", new AcceptableValueRange(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 Bind(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(); + 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(true)) UnityEngine.Object.DestroyImmediate(stand); + foreach (EffectArea effect in ward.PiecePrefab.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(effect); + ward.PiecePrefab.AddComponent(); + 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 __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(), m_price = BeltPrice.Value, m_stack = 1, + m_buyPlayerEffects = new EffectList(), m_tooltip = "", m_name = "Builder Belt", + m_requiredGlobalKey = "", m_buyKey = "", m_incrementKey = "" + }); + } + } + } +} diff --git a/Source/BuilderCamera/BuilderWard.cs b/Source/BuilderCamera/BuilderWard.cs new file mode 100644 index 0000000..684d3f1 --- /dev/null +++ b/Source/BuilderCamera/BuilderWard.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace PraetorisClient.BuilderCameraFeature +{ + public sealed class BuilderWard : MonoBehaviour, Hoverable + { + internal static readonly List Instances = new List(); + + private void Awake() + { + ZNetView view = GetComponent(); + 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"; + } +} diff --git a/Source/Core/Plugin.cs b/Source/Core/Plugin.cs index 36739b8..9812fed 100644 --- a/Source/Core/Plugin.cs +++ b/Source/Core/Plugin.cs @@ -122,6 +122,7 @@ public void Awake() Instance = this; BindConfig(); + BuilderCameraFeature.BuilderCamera.Initialize(Config); SynchronizationManager.OnConfigurationSynchronized += OnConfigurationSynchronized; PraetorisMagicEffects.Register(); if (epicLootLoaded) @@ -191,6 +192,7 @@ private void ApplyHarmonyPatches(bool epicLootLoaded) private void Update() { + BuilderCameraFeature.BuilderCamera.Update(); MaintenanceMode.UpdateScheduledWindow(); if (Game.instance == null) @@ -200,8 +202,14 @@ private void Update() ServerGuideFeature.ServerGuide.Update(); } + private void OnGUI() + { + BuilderCameraFeature.BuilderCamera.DrawStatus(); + } + private void OnDestroy() { + BuilderCameraFeature.BuilderCamera.Shutdown(); SynchronizationManager.OnConfigurationSynchronized -= OnConfigurationSynchronized; CleanseMeadFeature.Shutdown(); CreatureOwnerWardPiece.Shutdown();