Files
DevourClient/DevourClient/RadialMenuManager.cs
manafeng 5f73135eaa version 4.8
new revive function
god mode
instant interaction
custom shortcut keys
2026-01-15 21:53:32 +11:00

700 lines
25 KiB
C#

using System;
using System.Collections.Generic;
using MelonLoader;
using UnityEngine;
using DevourClient.Helpers;
using DevourClient.Localization;
using System.Globalization;
namespace DevourClient
{
// Manages the Z-key radial menu: building options, drawing the UI, and executing actions.
// Public static methods are called from the appropriate ClientMain lifecycle hooks.
internal static class RadialMenuManager
{
private static bool _active;
private static int _selectedIndex = -1;
private static readonly List<RadialOption> _options = new List<RadialOption>();
private static bool _cursorStateStored;
private static bool _prevCursorVisible;
private static CursorLockMode _prevCursorLockState;
private static bool _enabled = true; // Controls whether the radial menu is enabled
// Material used to draw radial arcs (GL immediate mode)
private static Material? _radialMaterial;
// Public property to enable/disable the radial menu
public static bool Enabled
{
get => _enabled;
set => _enabled = value;
}
private enum RadialActionType
{
None,
SpawnItem,
TeleportBase,
TeleportFixedPoint
}
private class RadialOption
{
public string Label = string.Empty;
public RadialActionType ActionType = RadialActionType.None;
public string Payload = string.Empty;
}
// Called from Update: handles input, builds / closes the radial menu and executes actions.
public static void HandleUpdate()
{
if (!_enabled || !Player.IsInGameOrLobby())
return;
KeyCode key = Settings.Settings.radialMenuKey;
if (key != KeyCode.None && Input.GetKeyDown(key))
{
BuildForCurrentScene();
}
if (key != KeyCode.None && Input.GetKeyUp(key) && _active)
{
if (_selectedIndex >= 0 && _selectedIndex < _options.Count)
{
ExecuteOption(_options[_selectedIndex]);
}
_active = false;
_selectedIndex = -1;
_options.Clear();
RestoreCursorState();
}
}
// Called from OnGUI: draws the radial menu if it is active.
public static void HandleOnGUI()
{
if (!_enabled || !_active || !Player.IsInGameOrLobby())
return;
Draw();
}
private static void BuildForCurrentScene()
{
_options.Clear();
_selectedIndex = -1;
string sceneName = Helpers.Map.GetActiveScene();
if (string.IsNullOrEmpty(sceneName) || sceneName == "Menu")
{
_active = false;
return;
}
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("First aid"),
ActionType = RadialActionType.SpawnItem,
Payload = "SurvivalFirstAid"
});
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("Battery"),
ActionType = RadialActionType.SpawnItem,
Payload = "SurvivalBattery"
});
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("TP Base"),
ActionType = RadialActionType.TeleportBase,
Payload = string.Empty
});
switch (sceneName)
{
case "Devour":
case "Anna":
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("TP Altar"),
ActionType = RadialActionType.TeleportFixedPoint,
Payload = "8.57 0.01 -65.19"
});
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("Hay"),
ActionType = RadialActionType.SpawnItem,
Payload = "SurvivalHay"
});
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("Gasoline"),
ActionType = RadialActionType.SpawnItem,
Payload = "SurvivalGasoline"
});
break;
case "Molly":
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("TP Altar"),
ActionType = RadialActionType.TeleportFixedPoint,
Payload = "18.12 -8.80 21.06"
});
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("Fuse"),
ActionType = RadialActionType.SpawnItem,
Payload = "SurvivalFuse"
});
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("RottenFood"),
ActionType = RadialActionType.SpawnItem,
Payload = "SurvivalRottenFood"
});
break;
case "Inn":
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("TP Fountain"),
ActionType = RadialActionType.TeleportFixedPoint,
Payload = "-3.43 0.06 24.31"
});
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("Bleach"),
ActionType = RadialActionType.SpawnItem,
Payload = "SurvivalBleach"
});
break;
case "Town":
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("TP Altar"),
ActionType = RadialActionType.TeleportFixedPoint,
Payload = "-56.88 7.17 -34.51"
});
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("Matchbox"),
ActionType = RadialActionType.SpawnItem,
Payload = "Matchbox-3"
});
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("Gasoline"),
ActionType = RadialActionType.SpawnItem,
Payload = "SurvivalGasoline"
});
break;
case "Slaughterhouse":
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("TP Altar"),
ActionType = RadialActionType.TeleportFixedPoint,
Payload = "26.68 4.01 -9.27"
});
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("Bone"),
ActionType = RadialActionType.SpawnItem,
Payload = "SurvivalBone"
});
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("Gasoline"),
ActionType = RadialActionType.SpawnItem,
Payload = "SurvivalGasoline"
});
break;
case "Manor":
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("TP Basin"),
ActionType = RadialActionType.TeleportFixedPoint,
Payload = "38.93 -4.62 -3.86"
});
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("Bleach"),
ActionType = RadialActionType.SpawnItem,
Payload = "SurvivalBleach"
});
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("Cake"),
ActionType = RadialActionType.SpawnItem,
Payload = "SurvivalCake"
});
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("Spade"),
ActionType = RadialActionType.SpawnItem,
Payload = "SurvivalSpade"
});
break;
case "Carnival":
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("TP Altar"),
ActionType = RadialActionType.TeleportFixedPoint,
Payload = "-114.65 4.07 -4.12"
});
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("Coin"),
ActionType = RadialActionType.SpawnItem,
Payload = "SurvivalCoin"
});
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("MusicBox"),
ActionType = RadialActionType.SpawnItem,
Payload = "MusicBox-Idle"
});
_options.Add(new RadialOption
{
Label = MultiLanguageSystem.Translate("DollHead"),
ActionType = RadialActionType.SpawnItem,
Payload = "SurvivalDollHead"
});
break;
}
if (_options.Count == 0)
{
_active = false;
return;
}
_active = true;
HideCursorForRadial();
}
private static void Draw()
{
Event e = Event.current;
if (e == null)
return;
bool isRepaint = e.type == EventType.Repaint;
float centerX = Screen.width / 2f;
float centerY = Screen.height / 2f;
Vector2 center = new Vector2(centerX, centerY);
float radiusOuter = 140f;
float radiusInner = 40f;
Vector2 mouse = e.mousePosition;
Vector2 dir = mouse - center;
float dist = dir.magnitude;
_selectedIndex = -1;
if (dist >= radiusInner && dist <= radiusOuter && _options.Count > 0)
{
// Normalize angles: convert GUI coordinates (y down) to math coordinates (y up)
// and treat "up" as 0° increasing clockwise.
Vector2 upDir = new Vector2(dir.x, -dir.y);
float mathAngle = Mathf.Atan2(upDir.y, upDir.x); // [-PI, PI], 0 is on the right, counterclockwise is positive
float logicalAngle = (Mathf.PI / 2f) - mathAngle;
if (logicalAngle < 0f)
{
logicalAngle += Mathf.PI * 2f;
}
float logicalSectorAngle = (Mathf.PI * 2f) / _options.Count;
int index = Mathf.Clamp(Mathf.FloorToInt(logicalAngle / logicalSectorAngle), 0, _options.Count - 1);
_selectedIndex = index;
}
int count = _options.Count;
if (count == 0)
return;
if (isRepaint)
{
EnsureRadialMaterial();
DrawFilledCircle(center, radiusOuter + 6f, new Color(0f, 0f, 0f, 0.55f));
}
// Sector layout and label radius
float logicalAnglePerSector = (Mathf.PI * 2f) / count;
float radiusFactor;
if (count <= 6)
radiusFactor = 0.55f;
else if (count == 7)
radiusFactor = 0.48f;
else if (count <= 8)
radiusFactor = 0.5f;
else
radiusFactor = 0.45f;
float labelRadius = radiusInner + (radiusOuter - radiusInner) * radiusFactor;
if (isRepaint)
{
for (int i = 0; i < count; i++)
{
float logicalStart = logicalAnglePerSector * i;
float logicalEnd = logicalAnglePerSector * (i + 1);
float displayStart = (Mathf.PI / 2f) - logicalStart;
float displayEnd = (Mathf.PI / 2f) - logicalEnd;
Color sectorColor = (i == _selectedIndex)
? new Color(0.15f, 0.7f, 0.3f, 0.8f)
: new Color(0.1f, 0.1f, 0.1f, 0.7f);
DrawFilledSector(center, radiusInner, radiusOuter, displayStart, displayEnd, sectorColor);
}
}
GUIStyle labelStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleCenter,
normal = { textColor = Color.white },
fontSize = (count <= 6) ? 14 : (count <= 8 ? 12 : 10),
wordWrap = true
};
for (int i = 0; i < count; i++)
{
float logicalMid = logicalAnglePerSector * (i + 0.5f);
float displayMid = (Mathf.PI / 2f) - logicalMid;
float lx = centerX + Mathf.Cos(displayMid) * labelRadius;
float ly = centerY - Mathf.Sin(displayMid) * labelRadius;
float arcLength = logicalAnglePerSector * labelRadius;
float arcFactor;
float minWidth;
float maxWidth;
if (count <= 6)
{
arcFactor = 0.8f;
minWidth = 60f;
maxWidth = 120f;
}
else if (count == 7)
{
arcFactor = 0.6f;
minWidth = 45f;
maxWidth = 75f;
}
else if (count <= 8)
{
arcFactor = 0.65f;
minWidth = 45f;
maxWidth = 80f;
}
else
{
arcFactor = 0.55f;
minWidth = 40f;
maxWidth = 70f;
}
float baseWidth = arcLength * arcFactor;
float labelWidth = Mathf.Clamp(baseWidth, minWidth, maxWidth);
float labelHeight = 32f;
Rect labelRect = new Rect(lx - labelWidth / 2f, ly - labelHeight / 2f, labelWidth, labelHeight);
GUI.Label(labelRect, _options[i].Label, labelStyle);
}
}
private static void EnsureRadialMaterial()
{
if (_radialMaterial != null)
return;
Shader shader = Shader.Find("Hidden/Internal-Colored");
if (shader == null)
return;
_radialMaterial = new Material(shader)
{
hideFlags = HideFlags.HideAndDontSave
};
_radialMaterial.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha);
_radialMaterial.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
_radialMaterial.SetInt("_Cull", (int)UnityEngine.Rendering.CullMode.Off);
_radialMaterial.SetInt("_ZWrite", 0);
}
private static void DrawFilledCircle(Vector2 center, float radius, Color color)
{
if (_radialMaterial == null)
return;
_radialMaterial.SetPass(0);
GL.PushMatrix();
GL.LoadPixelMatrix(0, Screen.width, Screen.height, 0);
GL.Begin(GL.TRIANGLES);
GL.Color(color);
const int steps = 64;
for (int i = 0; i < steps; i++)
{
float t0 = (float)i / steps;
float t1 = (float)(i + 1) / steps;
float ang0 = t0 * Mathf.PI * 2f;
float ang1 = t1 * Mathf.PI * 2f;
float x0 = center.x + Mathf.Cos(ang0) * radius;
float y0 = center.y - Mathf.Sin(ang0) * radius;
float x1 = center.x + Mathf.Cos(ang1) * radius;
float y1 = center.y - Mathf.Sin(ang1) * radius;
GL.Vertex3(center.x, center.y, 0f);
GL.Vertex3(x0, y0, 0f);
GL.Vertex3(x1, y1, 0f);
}
GL.End();
GL.PopMatrix();
}
private static void DrawFilledSector(Vector2 center, float innerRadius, float outerRadius,
float startAngle, float endAngle, Color color)
{
if (_radialMaterial == null)
return;
_radialMaterial.SetPass(0);
GL.PushMatrix();
GL.LoadPixelMatrix(0, Screen.width, Screen.height, 0);
GL.Begin(GL.TRIANGLES);
GL.Color(color);
int steps = Mathf.Max(8, Mathf.CeilToInt(Mathf.Abs(endAngle - startAngle) / (Mathf.PI / 24f)));
float delta = (endAngle - startAngle) / steps;
for (int i = 0; i < steps; i++)
{
float a0 = startAngle + delta * i;
float a1 = startAngle + delta * (i + 1);
Vector2 o0 = new Vector2(
center.x + Mathf.Cos(a0) * outerRadius,
center.y - Mathf.Sin(a0) * outerRadius);
Vector2 o1 = new Vector2(
center.x + Mathf.Cos(a1) * outerRadius,
center.y - Mathf.Sin(a1) * outerRadius);
Vector2 i0 = new Vector2(
center.x + Mathf.Cos(a0) * innerRadius,
center.y - Mathf.Sin(a0) * innerRadius);
Vector2 i1 = new Vector2(
center.x + Mathf.Cos(a1) * innerRadius,
center.y - Mathf.Sin(a1) * innerRadius);
GL.Vertex3(o0.x, o0.y, 0f);
GL.Vertex3(o1.x, o1.y, 0f);
GL.Vertex3(i1.x, i1.y, 0f);
GL.Vertex3(o0.x, o0.y, 0f);
GL.Vertex3(i1.x, i1.y, 0f);
GL.Vertex3(i0.x, i0.y, 0f);
}
GL.End();
GL.PopMatrix();
}
private static void ExecuteOption(RadialOption option)
{
if (option == null || option.ActionType == RadialActionType.None)
return;
try
{
switch (option.ActionType)
{
case RadialActionType.SpawnItem:
if (string.IsNullOrEmpty(option.Payload))
return;
ClientMain_HandleItemCarry(option.Payload);
break;
case RadialActionType.TeleportBase:
TeleportToBase();
break;
case RadialActionType.TeleportFixedPoint:
TeleportToFixedPoint(option.Payload);
break;
}
}
catch (Exception ex)
{
MelonLogger.Error($"Radial option execution failed: {ex.Message}");
}
}
private static void HideCursorForRadial()
{
if (!_cursorStateStored)
{
_prevCursorVisible = Cursor.visible;
_prevCursorLockState = Cursor.lockState;
_cursorStateStored = true;
}
Cursor.lockState = CursorLockMode.None;
Cursor.visible = true;
}
private static void RestoreCursorState()
{
if (!_cursorStateStored)
return;
Cursor.lockState = _prevCursorLockState;
Cursor.visible = _prevCursorVisible;
_cursorStateStored = false;
}
// Calls ClientMain.HandleItemCarry via reflection to avoid tight coupling,
// and falls back to Hacks.Misc.CarryObject if that fails.
private static void ClientMain_HandleItemCarry(string payload)
{
try
{
var type = typeof(ClientMain);
var method = type.GetMethod("HandleItemCarry",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
if (method != null)
{
method.Invoke(null, new object[] { payload });
return;
}
}
catch
{
// ignore and fallback
}
Hacks.Misc.CarryObject(payload);
}
// Teleports the player to a fixed world position.
// Payload format: "x y z" using '.' as decimal separator.
private static void TeleportToFixedPoint(string payload)
{
if (string.IsNullOrWhiteSpace(payload))
return;
try
{
string[] parts = payload.Split(new[] { ' ', '\t', ',' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 3)
return;
float x = float.Parse(parts[0], CultureInfo.InvariantCulture);
float y = float.Parse(parts[1], CultureInfo.InvariantCulture);
float z = float.Parse(parts[2], CultureInfo.InvariantCulture);
Il2Cpp.NolanBehaviour nb = Player.GetPlayer();
if (nb == null)
return;
Vector3 target = new Vector3(x, y, z);
nb.TeleportTo(target, Quaternion.identity);
}
catch (Exception ex)
{
MelonLogger.Error($"TeleportToFixedPoint failed: {ex.Message}");
}
}
// Teleports the player to the map-specific base coordinates (former RecallToBase logic).
private static void TeleportToBase()
{
try
{
Il2Cpp.NolanBehaviour nb = Player.GetPlayer();
if (nb == null)
{
MelonLogger.Warning("Player not found!");
return;
}
string sceneName = Helpers.Map.GetActiveScene();
Vector3 targetPos = Vector3.zero;
string mapName = "";
switch (sceneName)
{
case "Devour":
case "Anna": // Farmhouse
targetPos = new Vector3(5.03f, 4.20f, -50.02f);
mapName = "Farm";
break;
case "Molly": // Asylum
targetPos = new Vector3(17.52f, 1.38f, 7.04f);
mapName = "Asylum";
break;
case "Inn":
targetPos = new Vector3(3.53f, 0.84f, 2.47f);
mapName = "Inn";
break;
case "Town":
targetPos = new Vector3(-63.51f, 10.88f, -12.32f);
mapName = "Town";
break;
case "Slaughterhouse":
targetPos = new Vector3(6.09f, 0.70f, -17.58f);
mapName = "Slaughterhouse";
break;
case "Manor":
targetPos = new Vector3(3.67f, 1.32f, -23.34f);
mapName = "Manor";
break;
case "Carnival":
targetPos = new Vector3(-91.46f, 8.13f, -24.51f);
mapName = "Carnival";
break;
default:
MelonLogger.Warning($"Teleport not available for scene: {sceneName}");
return;
}
nb.locomotion.SetPosition(targetPos, false);
MelonLogger.Msg($"Teleported to {mapName} coordinates: X:{targetPos.x:F2} Y:{targetPos.y:F2} Z:{targetPos.z:F2}");
}
catch (Exception ex)
{
MelonLogger.Error($"Failed to teleport to base: {ex.Message}");
}
}
}
}