feat: add reusable virtual hud input

This commit is contained in:
ud18010
2026-07-30 11:14:56 +08:00
parent 3546d16632
commit 5853c5bd83
26 changed files with 6403 additions and 31 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 3f121035b068a0b49b64bffefd98ff4d
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,327 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &3334026408209144342
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 6000670627774682308}
- component: {fileID: 1609964632374445563}
- component: {fileID: 8894200138088445755}
m_Layer: 5
m_Name: UI_VirtualJoystick
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &6000670627774682308
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3334026408209144342}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 3357559548068760665}
- {fileID: 7096593815826968038}
- {fileID: 184437504964314081}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 260, y: 220}
m_SizeDelta: {x: 148, y: 148}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!225 &1609964632374445563
CanvasGroup:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3334026408209144342}
m_Enabled: 1
m_Alpha: 0.96
m_Interactable: 1
m_BlocksRaycasts: 1
m_IgnoreParentGroups: 0
--- !u!114 &8894200138088445755
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3334026408209144342}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 014f26c5eef24321b81f131f41a05a1d, type: 3}
m_Name:
m_EditorClassIdentifier:
joystickRoot: {fileID: 0}
knob: {fileID: 0}
deadZone: 0.18
acquireRadiusMultiplier: 1.6
--- !u!1 &4234464803126095867
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 7096593815826968038}
- component: {fileID: 5171389559328169491}
- component: {fileID: 3262724154744367238}
m_Layer: 5
m_Name: Ring
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &7096593815826968038
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4234464803126095867}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 6000670627774682308}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 148, y: 148}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &5171389559328169491
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4234464803126095867}
m_CullTransparentMesh: 1
--- !u!114 &3262724154744367238
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4234464803126095867}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 0
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: 1f9d86c7b2a247e0aa3d23c6ab2f5d64, type: 3}
m_Type: 0
m_PreserveAspect: 1
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!1 &4932454652913839351
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 3357559548068760665}
- component: {fileID: 8520179084870437592}
- component: {fileID: 940065713991431157}
- component: {fileID: 6616819295074457407}
m_Layer: 5
m_Name: Base
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &3357559548068760665
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4932454652913839351}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 6000670627774682308}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 148, y: 148}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &8520179084870437592
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4932454652913839351}
m_CullTransparentMesh: 1
--- !u!114 &940065713991431157
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4932454652913839351}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 0
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: 8d1b2e6b1a634e3c8d65e9f4a0a6f211, type: 3}
m_Type: 0
m_PreserveAspect: 1
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!114 &6616819295074457407
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4932454652913839351}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: cfabb0440166ab443bba8876756fdfa9, type: 3}
m_Name:
m_EditorClassIdentifier:
m_EffectColor: {r: 0.023529412, g: 0.0627451, b: 0.12156863, a: 0.41960785}
m_EffectDistance: {x: 0, y: -7}
m_UseGraphicAlpha: 1
--- !u!1 &7423296529846966760
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 184437504964314081}
- component: {fileID: 4587952894397174941}
- component: {fileID: 879436522938408951}
- component: {fileID: 5944806825172038846}
m_Layer: 5
m_Name: Knob
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &184437504964314081
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7423296529846966760}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 6000670627774682308}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 56, y: 56}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &4587952894397174941
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7423296529846966760}
m_CullTransparentMesh: 1
--- !u!114 &879436522938408951
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7423296529846966760}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 0
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: 35c648732e8a41a39a40930f784f18a2, type: 3}
m_Type: 0
m_PreserveAspect: 1
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!114 &5944806825172038846
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7423296529846966760}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: cfabb0440166ab443bba8876756fdfa9, type: 3}
m_Name:
m_EditorClassIdentifier:
m_EffectColor: {r: 0.023529412, g: 0.0627451, b: 0.12156863, a: 0.36078432}
m_EffectDistance: {x: 0, y: -5}
m_UseGraphicAlpha: 1
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: dab6990f4dac1fd4a81a818319ac4209
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -95,20 +95,31 @@ namespace XGame.EditorTools
} }
} }
public static void BuildVirtualInputFromCommandLine()
{
string jsonRoot = ResolveCommandLineJsonRoot();
string[] files =
{
"UI_VirtualJoystick.json",
"UI_VirtualActionPad.json"
};
int built = 0;
foreach (string file in files)
{
string output = BuildFromJsonFile(Path.Combine(jsonRoot, file));
if (string.IsNullOrEmpty(output))
{
throw new Exception("[UIGen] " + file + " build failed.");
}
built++;
}
Debug.Log("[UIGen] virtual input prefabs built: " + built);
}
public static void BuildAllFromCommandLine() public static void BuildAllFromCommandLine()
{ {
string jsonRoot = GetCommandLineValue("-uiJsonRoot"); string jsonRoot = ResolveCommandLineJsonRoot();
if (string.IsNullOrEmpty(jsonRoot))
{
jsonRoot = ResolveJsonRoot();
}
else if (!Path.IsPathRooted(jsonRoot))
{
string projectDir = Directory.GetParent(Application.dataPath).FullName;
string repoDir = Directory.GetParent(projectDir)?.FullName ?? projectDir;
jsonRoot = Path.Combine(repoDir, jsonRoot);
}
IReadOnlyList<string> outputs = BuildAll(jsonRoot); IReadOnlyList<string> outputs = BuildAll(jsonRoot);
if (outputs.Count == 0) if (outputs.Count == 0)
{ {
@@ -116,6 +127,24 @@ namespace XGame.EditorTools
} }
} }
private static string ResolveCommandLineJsonRoot()
{
string jsonRoot = GetCommandLineValue("-uiJsonRoot");
if (string.IsNullOrEmpty(jsonRoot))
{
return ResolveJsonRoot();
}
if (Path.IsPathRooted(jsonRoot))
{
return jsonRoot;
}
string projectDir = Directory.GetParent(Application.dataPath).FullName;
string repoDir = Directory.GetParent(projectDir)?.FullName ?? projectDir;
return Path.Combine(repoDir, jsonRoot);
}
public static ValidationResult Validate(string jsonPath) public static ValidationResult Validate(string jsonPath)
{ {
ValidationResult vr = new ValidationResult(); ValidationResult vr = new ValidationResult();
@@ -0,0 +1,83 @@
using NUnit.Framework;
using System.Reflection;
using UnityEngine;
using XGame;
using XGame.VirtualInput;
public sealed class LobbyWorldControllerVirtualJoystickTests
{
[Test]
public void MapMoveInputForTest_UsesJoystickVectorWithCameraAxes()
{
Vector3 dir = LobbyWorldController.MapMoveInputForTest(
new Vector2(1f, 0f),
Vector3.forward,
Vector3.right,
false,
false,
false,
false);
Assert.That(dir, Is.EqualTo(Vector3.right));
}
[Test]
public void MapMoveInputForTest_CombinesKeyboardAndJoystick()
{
Vector3 dir = LobbyWorldController.MapMoveInputForTest(
new Vector2(0f, 1f),
Vector3.forward,
Vector3.right,
true,
false,
false,
false);
Assert.That(dir, Is.EqualTo(Vector3.forward * 2f));
}
[Test]
public void ReleaseJoystickUIForTest_ClearsVirtualJoystickReferences()
{
GameObject controllerObj = new GameObject("LobbyWorldControllerTest");
GameObject joystickObj = new GameObject("UI_VirtualJoystick", typeof(RectTransform));
try
{
LobbyWorldController controller = controllerObj.AddComponent<LobbyWorldController>();
VirtualJoystick joystick = joystickObj.AddComponent<VirtualJoystick>();
SetPrivateField(controller, "joystickRoot", joystickObj.GetComponent<RectTransform>());
SetPrivateField(controller, "virtualJoystick", joystick);
InvokePrivate(controller, "ReleaseJoystickUI", false);
Assert.That(GetPrivateField(controller, "joystickRoot"), Is.Null);
Assert.That(GetPrivateField(controller, "virtualJoystick"), Is.Null);
}
finally
{
Object.DestroyImmediate(joystickObj);
Object.DestroyImmediate(controllerObj);
}
}
private static void SetPrivateField(object target, string name, object value)
{
FieldInfo field = target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(field, Is.Not.Null);
field.SetValue(target, value);
}
private static object GetPrivateField(object target, string name)
{
FieldInfo field = target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(field, Is.Not.Null);
return field.GetValue(target);
}
private static void InvokePrivate(object target, string name, params object[] args)
{
MethodInfo method = target.GetType().GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(method, Is.Not.Null);
method.Invoke(target, args);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a2bd7fd1fc87460095c485b27dcc0e2c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,210 @@
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using NUnit.Framework;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using XGame.VirtualInput;
public sealed class VirtualActionButtonTests
{
private readonly List<GameObject> _roots = new List<GameObject>();
[TearDown]
public void TearDown()
{
foreach (GameObject root in _roots)
{
if (root != null)
{
Object.DestroyImmediate(root);
}
}
_roots.Clear();
}
[Test]
public void PointerTap_EmitsDownThenTap()
{
VirtualActionButton button = CreateButton(ActionButtonId.Primary);
List<ActionButtonEvent> events = Capture(button);
button.HandlePointerDownForTest(new Vector2(100f, 100f), 1, Time.unscaledTime);
button.HandlePointerUpForTest(new Vector2(102f, 100f), 1, 0.1f);
Assert.That(events.Select(e => e.Phase), Is.EqualTo(new[] { ActionButtonPhase.Down, ActionButtonPhase.Tap }));
Assert.That(events.Last().ButtonId, Is.EqualTo(ActionButtonId.Primary));
}
[Test]
public void PointerDragPastThreshold_EmitsReleaseWithDirection()
{
VirtualActionButton button = CreateButton(ActionButtonId.Skill1);
List<ActionButtonEvent> events = Capture(button);
button.HandlePointerDownForTest(new Vector2(100f, 100f), 1, 0f);
button.HandleDragForTest(new Vector2(160f, 100f), 1, 0.1f);
button.HandlePointerUpForTest(new Vector2(160f, 100f), 1, 0.2f);
ActionButtonEvent release = events.Last();
Assert.That(release.Phase, Is.EqualTo(ActionButtonPhase.Release));
Assert.That(release.Direction.x, Is.EqualTo(1f).Within(0.001f));
Assert.That(release.Direction.y, Is.EqualTo(0f).Within(0.001f));
Assert.That(release.Distance01, Is.GreaterThan(0.7f));
}
[Test]
public void PointerUpInsideCancelArea_EmitsCancel()
{
VirtualActionButton button = CreateButton(ActionButtonId.Skill2);
RectTransform cancelArea = CreateRect("CancelArea", new Vector2(200f, 100f), new Vector2(80f, 80f));
button.SetCancelArea(cancelArea);
List<ActionButtonEvent> events = Capture(button);
button.HandlePointerDownForTest(new Vector2(100f, 100f), 1, 0f);
button.HandleDragForTest(new Vector2(200f, 100f), 1, 0.1f);
button.HandlePointerUpForTest(new Vector2(200f, 100f), 1, 0.2f);
Assert.That(events.Last().Phase, Is.EqualTo(ActionButtonPhase.Cancel));
Assert.That(events.Last().IsCanceled, Is.True);
}
[Test]
public void Cooldown_EmitsBlockedOnly()
{
VirtualActionButton button = CreateButton(ActionButtonId.Skill3);
List<ActionButtonEvent> events = Capture(button);
button.SetCooldown(3f, 5f);
button.HandlePointerDownForTest(new Vector2(100f, 100f), 1, 0f);
Assert.That(events.Select(e => e.Phase), Is.EqualTo(new[] { ActionButtonPhase.Blocked }));
}
[Test]
public void ChargesZero_EmitsBlockedAndShowsChargeText()
{
VirtualActionButton button = CreateButton(ActionButtonId.Skill4, out _, out TMP_Text chargeText);
List<ActionButtonEvent> events = Capture(button);
button.SetCharges(0);
button.HandlePointerDownForTest(new Vector2(100f, 100f), 1, 0f);
Assert.That(events.Single().Phase, Is.EqualTo(ActionButtonPhase.Blocked));
Assert.That(chargeText.text, Is.EqualTo("0"));
}
[Test]
public void BindNodes_FindsPrefabChildrenWithButtonPrefix()
{
VirtualActionButton button = CreateButton(ActionButtonId.Skill1, "Skill1", out _, out TMP_Text chargeText);
button.SetCharges(2);
Assert.That(chargeText.text, Is.EqualTo("2"));
Assert.That(chargeText.gameObject.activeSelf, Is.True);
}
[Test]
public void Hold_EmitsHoldStartTickAndEnd()
{
VirtualActionButton button = CreateButton(ActionButtonId.Primary);
List<ActionButtonEvent> events = Capture(button);
button.HandlePointerDownForTest(new Vector2(100f, 100f), 1, 0f);
button.HandleDragForTest(new Vector2(102f, 100f), 1, 0.4f);
button.HandleDragForTest(new Vector2(102f, 100f), 1, 0.51f);
button.HandlePointerUpForTest(new Vector2(102f, 100f), 1, 0.6f);
Assert.That(events.Select(e => e.Phase), Does.Contain(ActionButtonPhase.HoldStart));
Assert.That(events.Select(e => e.Phase), Does.Contain(ActionButtonPhase.HoldTick));
Assert.That(events.Select(e => e.Phase), Does.Contain(ActionButtonPhase.HoldEnd));
}
[Test]
public void Update_EmitsHoldWhilePointerIsStationary()
{
VirtualActionButton button = CreateButton(ActionButtonId.Primary);
List<ActionButtonEvent> events = Capture(button);
float startedAt = Time.unscaledTime - 0.4f;
button.HandlePointerDownForTest(new Vector2(100f, 100f), 1, startedAt);
InvokePrivate(button, "Update");
Assert.That(events.Select(e => e.Phase), Does.Contain(ActionButtonPhase.HoldStart));
}
[Test]
public void OnDisable_CancelsActivePointer()
{
VirtualActionButton button = CreateButton(ActionButtonId.Skill2);
List<ActionButtonEvent> events = Capture(button);
button.HandlePointerDownForTest(new Vector2(100f, 100f), 1, 0f);
InvokePrivate(button, "OnDisable");
Assert.That(events.Select(e => e.Phase), Is.EqualTo(new[] { ActionButtonPhase.Down, ActionButtonPhase.Cancel }));
}
private VirtualActionButton CreateButton(ActionButtonId id)
{
return CreateButton(id, out _, out _);
}
private VirtualActionButton CreateButton(ActionButtonId id, out Image cooldownMask, out TMP_Text chargeText)
{
return CreateButton(id, null, out cooldownMask, out chargeText);
}
private VirtualActionButton CreateButton(ActionButtonId id, string childPrefix, out Image cooldownMask, out TMP_Text chargeText)
{
RectTransform rect = CreateRect("Btn_" + id, new Vector2(100f, 100f), new Vector2(100f, 100f));
string prefix = string.IsNullOrEmpty(childPrefix) ? string.Empty : childPrefix + "_";
new GameObject(prefix + "Icon", typeof(RectTransform), typeof(Image)).transform.SetParent(rect, false);
cooldownMask = new GameObject(prefix + "CooldownMask", typeof(RectTransform), typeof(Image)).GetComponent<Image>();
cooldownMask.transform.SetParent(rect, false);
chargeText = new GameObject(prefix + "ChargeText", typeof(RectTransform), typeof(TextMeshProUGUI)).GetComponent<TMP_Text>();
chargeText.transform.SetParent(rect, false);
new GameObject(prefix + "CooldownText", typeof(RectTransform), typeof(TextMeshProUGUI)).transform.SetParent(rect, false);
new GameObject(prefix + "AimIndicator", typeof(RectTransform)).transform.SetParent(rect, false);
VirtualActionButton button = rect.gameObject.AddComponent<VirtualActionButton>();
button.ButtonId = id;
SetPrivateField(button, "screenCenterOverride", new Vector2(100f, 100f));
SetPrivateField(button, "screenRadiusOverride", 50f);
InvokePrivate(button, "Awake");
return button;
}
private RectTransform CreateRect(string name, Vector2 position, Vector2 size)
{
GameObject go = new GameObject(name, typeof(RectTransform));
_roots.Add(go);
RectTransform rect = go.GetComponent<RectTransform>();
rect.position = position;
rect.sizeDelta = size;
return rect;
}
private static List<ActionButtonEvent> Capture(VirtualActionButton button)
{
var events = new List<ActionButtonEvent>();
button.ButtonEvent += events.Add;
return events;
}
private static void SetPrivateField(object target, string name, object value)
{
FieldInfo field = target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(field, Is.Not.Null);
field.SetValue(target, value);
}
private static void InvokePrivate(object target, string name, params object[] args)
{
MethodInfo method = target.GetType().GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(method, Is.Not.Null);
method.Invoke(target, args);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2742fc0e43284790950df7a4a8b5cc6e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,117 @@
using System.Collections.Generic;
using System.Reflection;
using NUnit.Framework;
using UnityEngine;
using XGame.VirtualInput;
public sealed class VirtualActionPadTests
{
private GameObject _root;
[TearDown]
public void TearDown()
{
if (_root != null)
{
Object.DestroyImmediate(_root);
_root = null;
}
}
[Test]
public void Awake_FindsFiveButtonsByNameAndAssignsIds()
{
VirtualActionPad pad = CreatePadWithButtons();
Assert.That(pad.GetButton(ActionButtonId.Primary), Is.Not.Null);
Assert.That(pad.GetButton(ActionButtonId.Skill1), Is.Not.Null);
Assert.That(pad.GetButton(ActionButtonId.Skill2), Is.Not.Null);
Assert.That(pad.GetButton(ActionButtonId.Skill3), Is.Not.Null);
Assert.That(pad.GetButton(ActionButtonId.Skill4), Is.Not.Null);
Assert.That(pad.GetButton(ActionButtonId.Skill4).ButtonId, Is.EqualTo(ActionButtonId.Skill4));
}
[Test]
public void ChildButtonEvent_IsForwardedByPad()
{
VirtualActionPad pad = CreatePadWithButtons();
var events = new List<ActionButtonEvent>();
pad.ButtonEvent += events.Add;
pad.GetButton(ActionButtonId.Primary).HandlePointerDownForTest(new Vector2(100f, 100f), 1, 0f);
Assert.That(events.Count, Is.EqualTo(1));
Assert.That(events[0].ButtonId, Is.EqualTo(ActionButtonId.Primary));
Assert.That(events[0].Phase, Is.EqualTo(ActionButtonPhase.Down));
}
[Test]
public void PadStateApi_ForwardsToChildButton()
{
VirtualActionPad pad = CreatePadWithButtons();
var events = new List<ActionButtonEvent>();
pad.ButtonEvent += events.Add;
pad.SetCharges(ActionButtonId.Skill1, 0);
pad.GetButton(ActionButtonId.Skill1).HandlePointerDownForTest(new Vector2(100f, 100f), 1, 0f);
Assert.That(events.Count, Is.EqualTo(1));
Assert.That(events[0].Phase, Is.EqualTo(ActionButtonPhase.Blocked));
}
[Test]
public void SetCancelAreaVisible_TogglesCancelArea()
{
VirtualActionPad pad = CreatePadWithButtons();
Transform cancel = _root.transform.Find("CancelArea");
Assert.That(cancel, Is.Not.Null);
pad.SetCancelAreaVisible(true);
Assert.That(cancel.gameObject.activeSelf, Is.True);
pad.SetCancelAreaVisible(false);
Assert.That(cancel.gameObject.activeSelf, Is.False);
}
private VirtualActionPad CreatePadWithButtons()
{
_root = new GameObject("UI_VirtualActionPad", typeof(RectTransform));
CreateButtonChild("Btn_Primary", ActionButtonId.Primary);
CreateButtonChild("Btn_Skill1", ActionButtonId.Skill1);
CreateButtonChild("Btn_Skill2", ActionButtonId.Skill2);
CreateButtonChild("Btn_Skill3", ActionButtonId.Skill3);
CreateButtonChild("Btn_Skill4", ActionButtonId.Skill4);
GameObject cancel = new GameObject("CancelArea", typeof(RectTransform));
cancel.transform.SetParent(_root.transform, false);
cancel.SetActive(false);
VirtualActionPad pad = _root.AddComponent<VirtualActionPad>();
InvokePrivate(pad, "Awake");
return pad;
}
private void CreateButtonChild(string name, ActionButtonId id)
{
GameObject child = new GameObject(name, typeof(RectTransform));
child.transform.SetParent(_root.transform, false);
VirtualActionButton button = child.AddComponent<VirtualActionButton>();
button.ButtonId = id;
SetPrivateField(button, "screenCenterOverride", new Vector2(100f, 100f));
SetPrivateField(button, "screenRadiusOverride", 50f);
InvokePrivate(button, "Awake");
}
private static void SetPrivateField(object target, string name, object value)
{
FieldInfo field = target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(field, Is.Not.Null);
field.SetValue(target, value);
}
private static void InvokePrivate(object target, string name, params object[] args)
{
MethodInfo method = target.GetType().GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(method, Is.Not.Null);
method.Invoke(target, args);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c66bc2b2fc0c435aa305004fd1d560e7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,46 @@
using System.IO;
using NUnit.Framework;
#if UNITY_EDITOR
using XGame.EditorTools;
#endif
public sealed class VirtualInputPrefabLayoutTests
{
[Test]
public void VirtualJoystickJson_DefinesReusableJoystickPrefab()
{
string json = File.ReadAllText("../Doc/UIPrefabCreater/UI_VirtualJoystick.json");
Assert.That(json, Does.Contain("\"prefabName\": \"UI_VirtualJoystick\""));
Assert.That(json, Does.Contain("\"prefabPath\": \"Assets/Game/Art/UI/Prefab/UI_VirtualJoystick.prefab\""));
Assert.That(json, Does.Contain("\"name\": \"Knob\""));
Assert.That(json, Does.Contain("\"typeName\": \"XGame.VirtualInput.VirtualJoystick\""));
}
[Test]
public void VirtualActionPadJson_DefinesFiveButtonsAndCancelArea()
{
string json = File.ReadAllText("../Doc/UIPrefabCreater/UI_VirtualActionPad.json");
Assert.That(json, Does.Contain("\"prefabName\": \"UI_VirtualActionPad\""));
Assert.That(json, Does.Contain("\"prefabPath\": \"Assets/Game/Art/UI/Prefab/UI_VirtualActionPad.prefab\""));
Assert.That(json, Does.Contain("\"typeName\": \"XGame.VirtualInput.VirtualActionPad\""));
Assert.That(json, Does.Contain("\"Btn_Primary\""));
Assert.That(json, Does.Contain("\"Btn_Skill1\""));
Assert.That(json, Does.Contain("\"Btn_Skill2\""));
Assert.That(json, Does.Contain("\"Btn_Skill3\""));
Assert.That(json, Does.Contain("\"Btn_Skill4\""));
Assert.That(json, Does.Contain("\"CancelArea\""));
Assert.That(json, Does.Contain("\"Primary_CooldownMask\""));
Assert.That(json, Does.Contain("\"Skill1_ChargeText\""));
}
#if UNITY_EDITOR
[Test]
public void VirtualInputJsonFiles_PassPrefabBuilderValidation()
{
Assert.That(JsonUIPrefabBuilder.Validate("../Doc/UIPrefabCreater/UI_VirtualJoystick.json").Ok, Is.True);
Assert.That(JsonUIPrefabBuilder.Validate("../Doc/UIPrefabCreater/UI_VirtualActionPad.json").Ok, Is.True);
}
#endif
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e9cad9d7487d498986dca6b134d5a90b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,112 @@
using System;
using System.Reflection;
using NUnit.Framework;
using UnityEngine;
using XGame.VirtualInput;
public sealed class VirtualJoystickTests
{
private GameObject _root;
[TearDown]
public void TearDown()
{
if (_root != null)
{
UnityEngine.Object.DestroyImmediate(_root);
_root = null;
}
}
[Test]
public void ApplyPointerInsideDeadZone_ReportsInactiveAndZero()
{
VirtualJoystick joystick = CreateJoystick(out _);
ApplyPointer(joystick, new Vector2(106f, 100f));
Assert.That(joystick.IsActive, Is.False);
Assert.That(joystick.Value, Is.EqualTo(Vector2.zero));
}
[Test]
public void ApplyPointerBeyondRadius_ClampsValueAndKnobTravel()
{
VirtualJoystick joystick = CreateJoystick(out RectTransform knob);
ApplyPointer(joystick, new Vector2(300f, 100f));
Assert.That(joystick.IsActive, Is.True);
Assert.That(joystick.Value.x, Is.EqualTo(1f).Within(0.001f));
Assert.That(joystick.Value.y, Is.EqualTo(0f).Within(0.001f));
Assert.That(knob.anchoredPosition.x, Is.EqualTo(24f).Within(0.001f));
Assert.That(knob.anchoredPosition.y, Is.EqualTo(0f).Within(0.001f));
}
[Test]
public void ReleasePointer_ResetsValueAndRaisesEnded()
{
VirtualJoystick joystick = CreateJoystick(out RectTransform knob);
int ended = 0;
joystick.Ended += () => ended++;
ApplyPointer(joystick, new Vector2(300f, 100f));
ReleasePointer(joystick);
Assert.That(joystick.IsActive, Is.False);
Assert.That(joystick.Value, Is.EqualTo(Vector2.zero));
Assert.That(knob.anchoredPosition, Is.EqualTo(Vector2.zero));
Assert.That(ended, Is.EqualTo(1));
}
[Test]
public void IsScreenPositionInAcquireArea_UsesAcquireRadiusMultiplier()
{
VirtualJoystick joystick = CreateJoystick(out _);
Assert.That(joystick.IsScreenPositionInAcquireArea(new Vector2(179f, 100f)), Is.True);
Assert.That(joystick.IsScreenPositionInAcquireArea(new Vector2(181f, 100f)), Is.False);
}
private VirtualJoystick CreateJoystick(out RectTransform knob)
{
_root = new GameObject("UI_VirtualJoystick", typeof(RectTransform));
RectTransform rootRect = _root.GetComponent<RectTransform>();
rootRect.sizeDelta = new Vector2(100f, 100f);
GameObject knobObject = new GameObject("Knob", typeof(RectTransform));
knobObject.transform.SetParent(_root.transform, false);
knob = knobObject.GetComponent<RectTransform>();
knob.sizeDelta = new Vector2(52f, 52f);
VirtualJoystick joystick = _root.AddComponent<VirtualJoystick>();
SetPrivateField(joystick, "screenCenterOverride", new Vector2(100f, 100f));
SetPrivateField(joystick, "screenRadiusOverride", 50f);
InvokePrivate(joystick, "Awake");
return joystick;
}
private static void ApplyPointer(VirtualJoystick joystick, Vector2 screenPosition)
{
InvokePrivate(joystick, "ApplyPointer", screenPosition);
}
private static void ReleasePointer(VirtualJoystick joystick)
{
InvokePrivate(joystick, "ReleasePointer");
}
private static void SetPrivateField(object target, string name, object value)
{
FieldInfo field = target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(field, Is.Not.Null);
field.SetValue(target, value);
}
private static void InvokePrivate(object target, string name, params object[] args)
{
MethodInfo method = target.GetType().GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(method, Is.Not.Null);
method.Invoke(target, args);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 801fbb81f7954a04a5673c91a4d1a8a0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -5,6 +5,7 @@ using UnityEngine;
using UnityEngine.EventSystems; using UnityEngine.EventSystems;
using UnityEngine.Rendering; using UnityEngine.Rendering;
using UnityEngine.UI; using UnityEngine.UI;
using XGame.VirtualInput;
using XWorld.Framework.Protocol; using XWorld.Framework.Protocol;
using UObject = UnityEngine.Object; using UObject = UnityEngine.Object;
@@ -29,7 +30,7 @@ namespace XGame
public bool IsPlaceholder; // Obj 当前是胶囊占位(真实角色预设还在异步加载),加载完成后会原地升级 public bool IsPlaceholder; // Obj 当前是胶囊占位(真实角色预设还在异步加载),加载完成后会原地升级
} }
private const string JoystickPrefabPath = "Assets/Game/Art/UI/Prefab/UI_LobbyJoystick.prefab"; private const string JoystickPrefabPath = "Assets/Game/Art/UI/Prefab/UI_VirtualJoystick.prefab";
private const string CharacterConfigResourcePath = "Config/CharacterConfig"; private const string CharacterConfigResourcePath = "Config/CharacterConfig";
private const string SelectedCharacterKey = "CharacterSwitch.SelectedCharacterId"; private const string SelectedCharacterKey = "CharacterSwitch.SelectedCharacterId";
private const float MoveSpeed = 1f; private const float MoveSpeed = 1f;
@@ -39,7 +40,6 @@ namespace XGame
private const float DirectionChangeMinInterval = 0.1f; private const float DirectionChangeMinInterval = 0.1f;
private const float DirectionChangeDot = 0.98f; private const float DirectionChangeDot = 0.98f;
private const float JoystickRadius = 74f; private const float JoystickRadius = 74f;
private const float JoystickKnobRadius = 28f;
private const float JoystickPadding = 100.0f; private const float JoystickPadding = 100.0f;
private const float JoystickDeadZone = 0.18f; private const float JoystickDeadZone = 0.18f;
private const float JoystickAcquireRadiusMultiplier = 1.6f; private const float JoystickAcquireRadiusMultiplier = 1.6f;
@@ -83,6 +83,7 @@ namespace XGame
private float lastPinchDistance; private float lastPinchDistance;
private RectTransform joystickRoot; private RectTransform joystickRoot;
private RectTransform joystickWidget; // 视觉容器:新预设里的 "Joystick" 子节点(自带定位);找不到则回退为 joystickRoot private RectTransform joystickWidget; // 视觉容器:新预设里的 "Joystick" 子节点(自带定位);找不到则回退为 joystickRoot
private VirtualJoystick virtualJoystick;
private RectTransform joystickKnob; private RectTransform joystickKnob;
private bool joystickMouseCaptured; private bool joystickMouseCaptured;
private int joystickTouchFingerId = -1; private int joystickTouchFingerId = -1;
@@ -151,6 +152,7 @@ namespace XGame
{ {
joystickRoot.gameObject.SetActive(false); joystickRoot.gameObject.SetActive(false);
} }
OnVirtualJoystickEnded();
} }
public void RefreshSelectedFigure() public void RefreshSelectedFigure()
@@ -218,6 +220,11 @@ namespace XGame
UpdateCamera(); UpdateCamera();
} }
private void OnDestroy()
{
ReleaseJoystickUI(true);
}
private void ApplyState(LobbyStateMsg state) private void ApplyState(LobbyStateMsg state)
{ {
ReconcileSelfPlayerId(state); ReconcileSelfPlayerId(state);
@@ -382,7 +389,6 @@ namespace XGame
return; return;
} }
UpdateJoystick();
Vector3 dir = GetMoveDirection(); Vector3 dir = GetMoveDirection();
bool moving = dir.sqrMagnitude > 0.001f; bool moving = dir.sqrMagnitude > 0.001f;
if (moving) if (moving)
@@ -418,17 +424,36 @@ namespace XGame
Camera cam = Camera.main; Camera cam = Camera.main;
Vector3 forward = cam != null ? cam.transform.forward : Vector3.forward; Vector3 forward = cam != null ? cam.transform.forward : Vector3.forward;
Vector3 right = cam != null ? cam.transform.right : Vector3.right; Vector3 right = cam != null ? cam.transform.right : Vector3.right;
forward.y = 0f; return MapMoveInputForTest(
right.y = 0f; joystickActive ? joystick : Vector2.zero,
forward = forward.sqrMagnitude > 0.001f ? forward.normalized : Vector3.forward; forward,
right = right.sqrMagnitude > 0.001f ? right.normalized : Vector3.right; right,
Input.GetKey(KeyCode.W),
Input.GetKey(KeyCode.S),
Input.GetKey(KeyCode.A),
Input.GetKey(KeyCode.D));
}
public static Vector3 MapMoveInputForTest(
Vector2 joystickInput,
Vector3 cameraForward,
Vector3 cameraRight,
bool keyW,
bool keyS,
bool keyA,
bool keyD)
{
cameraForward.y = 0f;
cameraRight.y = 0f;
Vector3 forward = cameraForward.sqrMagnitude > 0.001f ? cameraForward.normalized : Vector3.forward;
Vector3 right = cameraRight.sqrMagnitude > 0.001f ? cameraRight.normalized : Vector3.right;
Vector3 dir = Vector3.zero; Vector3 dir = Vector3.zero;
if (Input.GetKey(KeyCode.W)) dir += forward; if (keyW) dir += forward;
if (Input.GetKey(KeyCode.S)) dir -= forward; if (keyS) dir -= forward;
if (Input.GetKey(KeyCode.A)) dir -= right; if (keyA) dir -= right;
if (Input.GetKey(KeyCode.D)) dir += right; if (keyD) dir += right;
if (joystickActive) dir += right * joystick.x + forward * joystick.y; if (joystickInput.sqrMagnitude > 0.001f) dir += right * joystickInput.x + forward * joystickInput.y;
return dir; return dir;
} }
@@ -917,7 +942,7 @@ namespace XGame
rootObj = Instantiate(prefabObj); rootObj = Instantiate(prefabObj);
UIManager.AttachTo(UILayer.HUD, rootObj); UIManager.AttachTo(UILayer.HUD, rootObj);
rootObj.name = "UI_LobbyJoystick"; rootObj.name = "UI_VirtualJoystick";
joystickRoot = rootObj.GetComponent<RectTransform>(); joystickRoot = rootObj.GetComponent<RectTransform>();
if (joystickRoot == null) if (joystickRoot == null)
{ {
@@ -926,6 +951,16 @@ namespace XGame
joystickRoot.localScale = Vector3.one; // 兜底:设备包 prefab 根缩放异常时仍可见 joystickRoot.localScale = Vector3.one; // 兜底:设备包 prefab 根缩放异常时仍可见
// 视觉容器:新预设里是 "Joystick" 子节点(预设自带定位);找不到则用根并由 ApplyJoystickLayout 代码定位 // 视觉容器:新预设里是 "Joystick" 子节点(预设自带定位);找不到则用根并由 ApplyJoystickLayout 代码定位
virtualJoystick = rootObj.GetComponent<VirtualJoystick>();
if (virtualJoystick == null)
{
virtualJoystick = rootObj.AddComponent<VirtualJoystick>();
}
virtualJoystick.Changed -= OnVirtualJoystickChanged;
virtualJoystick.Changed += OnVirtualJoystickChanged;
virtualJoystick.Ended -= OnVirtualJoystickEnded;
virtualJoystick.Ended += OnVirtualJoystickEnded;
Transform widget = joystickRoot.FindTransform("Joystick"); Transform widget = joystickRoot.FindTransform("Joystick");
joystickWidget = (widget as RectTransform) ?? joystickRoot; joystickWidget = (widget as RectTransform) ?? joystickRoot;
if (joystickWidget == joystickRoot) if (joystickWidget == joystickRoot)
@@ -939,9 +974,7 @@ namespace XGame
if (joystickKnob == null) if (joystickKnob == null)
{ {
Debug.LogError("[LobbyWorldController] joystick prefab missing Knob node: " + JoystickPrefabPath); Debug.LogError("[LobbyWorldController] joystick prefab missing Knob node: " + JoystickPrefabPath);
Destroy(rootObj); ReleaseJoystickUI(true);
joystickRoot = null;
joystickWidget = null;
yield break; yield break;
} }
@@ -949,6 +982,59 @@ namespace XGame
UpdateJoystickVisual(); UpdateJoystickVisual();
} }
private void ReleaseJoystickUI(bool destroyObject)
{
if (virtualJoystick != null)
{
virtualJoystick.Changed -= OnVirtualJoystickChanged;
virtualJoystick.Ended -= OnVirtualJoystickEnded;
}
GameObject rootObj = joystickRoot != null ? joystickRoot.gameObject : null;
virtualJoystick = null;
joystickRoot = null;
joystickWidget = null;
joystickKnob = null;
joystickMouseCaptured = false;
joystickTouchFingerId = -1;
joystickLoading = false;
OnVirtualJoystickEnded();
if (destroyObject && rootObj != null)
{
DestroyUnityObject(rootObj);
}
}
private static void DestroyUnityObject(UObject obj)
{
if (obj == null)
{
return;
}
if (Application.isPlaying)
{
Destroy(obj);
}
else
{
DestroyImmediate(obj);
}
}
private void OnVirtualJoystickChanged(Vector2 value, bool active)
{
joystick = active ? value : Vector2.zero;
joystickActive = active;
}
private void OnVirtualJoystickEnded()
{
joystick = Vector2.zero;
joystickActive = false;
}
private Transform GetHudParent() private Transform GetHudParent()
{ {
RectTransform hud = UIManager.GetLayer(UILayer.HUD); RectTransform hud = UIManager.GetLayer(UILayer.HUD);
@@ -1229,9 +1315,7 @@ namespace XGame
private bool IsInJoystickArea(Vector2 screenPosition) private bool IsInJoystickArea(Vector2 screenPosition)
{ {
Vector2 center = GetJoystickScreenCenter(); return virtualJoystick != null && virtualJoystick.IsScreenPositionInAcquireArea(screenPosition);
float radius = GetJoystickScreenRadius();
return Vector2.Distance(screenPosition, center) <= radius * JoystickAcquireRadiusMultiplier;
} }
private bool EnsureCharacterConfig() private bool EnsureCharacterConfig()
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 0d025722ff61490e967ec8bb7e4d68dd
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,498 @@
using System;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace XGame.VirtualInput
{
public enum ActionButtonId
{
Primary,
Skill1,
Skill2,
Skill3,
Skill4
}
public enum ActionButtonPhase
{
Down,
Drag,
HoldStart,
HoldTick,
HoldEnd,
Tap,
Release,
Cancel,
Blocked
}
public readonly struct ActionButtonEvent
{
public ActionButtonEvent(
ActionButtonId buttonId,
ActionButtonPhase phase,
Vector2 screenPosition,
Vector2 localVector,
Vector2 direction,
float distance01,
float heldSeconds,
bool isCanceled,
int pointerId)
{
ButtonId = buttonId;
Phase = phase;
ScreenPosition = screenPosition;
LocalVector = localVector;
Direction = direction;
Distance01 = distance01;
HeldSeconds = heldSeconds;
IsCanceled = isCanceled;
PointerId = pointerId;
}
public ActionButtonId ButtonId { get; }
public ActionButtonPhase Phase { get; }
public Vector2 ScreenPosition { get; }
public Vector2 LocalVector { get; }
public Vector2 Direction { get; }
public float Distance01 { get; }
public float HeldSeconds { get; }
public bool IsCanceled { get; }
public int PointerId { get; }
}
public sealed class VirtualActionButton : MonoBehaviour, IPointerDownHandler, IDragHandler, IPointerUpHandler
{
[SerializeField] public ActionButtonId ButtonId;
[SerializeField] private float holdThresholdSeconds = 0.35f;
[SerializeField] private float holdTickIntervalSeconds = 0.1f;
[SerializeField] private float releaseDistanceThreshold01 = 0.25f;
[SerializeField] private float maxDragRadiusMultiplier = 1.5f;
private Image icon;
private Image cooldownMask;
private TMP_Text cooldownText;
private TMP_Text chargeText;
private GameObject aimIndicator;
private RectTransform cancelArea;
private RectTransform rectTransform;
private Button unityButton;
private CanvasGroup canvasGroup;
private bool enabledByState = true;
private int charges = -1;
private float cooldownRemaining;
private float cooldownDuration;
private bool pointerDown;
private int activePointerId = int.MinValue;
private float pointerDownTime;
private bool holdStarted;
private float nextHoldTickTime;
private Vector2 lastScreenPosition;
private ActionButtonEvent lastDragEvent;
private Vector2? screenCenterOverride;
private float? screenRadiusOverride;
public bool IsInteractable => enabledByState && cooldownRemaining <= 0f && charges != 0;
public event Action<ActionButtonEvent> ButtonEvent;
private void Awake()
{
BindNodes();
RefreshVisuals();
}
private void OnDisable()
{
CancelActivePointer(Time.unscaledTime);
SetAimVisible(false);
}
private void Update()
{
if (cooldownRemaining > 0f)
{
cooldownRemaining = Mathf.Max(0f, cooldownRemaining - Time.unscaledDeltaTime);
RefreshVisuals();
}
if (pointerDown)
{
MaybeEmitHold(Time.unscaledTime, activePointerId, lastScreenPosition);
}
}
public void OnPointerDown(PointerEventData eventData)
{
HandlePointerDown(eventData.position, eventData.pointerId, Time.unscaledTime);
}
public void OnDrag(PointerEventData eventData)
{
HandleDrag(eventData.position, eventData.pointerId, Time.unscaledTime);
}
public void OnPointerUp(PointerEventData eventData)
{
HandlePointerUp(eventData.position, eventData.pointerId, Time.unscaledTime);
}
public void HandlePointerDownForTest(Vector2 screenPosition, int pointerId, float now)
{
HandlePointerDown(screenPosition, pointerId, now);
}
public void HandleDragForTest(Vector2 screenPosition, int pointerId, float now)
{
HandleDrag(screenPosition, pointerId, now);
}
public void HandlePointerUpForTest(Vector2 screenPosition, int pointerId, float now)
{
HandlePointerUp(screenPosition, pointerId, now);
}
public void SetCooldown(float remainingSeconds, float durationSeconds)
{
cooldownRemaining = Mathf.Max(0f, remainingSeconds);
cooldownDuration = Mathf.Max(0f, durationSeconds);
RefreshVisuals();
}
public void SetCharges(int count)
{
charges = count;
RefreshVisuals();
}
public void SetButtonEnabled(bool enabled)
{
enabledByState = enabled;
RefreshVisuals();
}
public void SetCancelArea(RectTransform area)
{
cancelArea = area;
}
public void SetIcon(Sprite sprite)
{
if (icon == null)
{
BindNodes();
}
if (icon != null)
{
icon.sprite = sprite;
icon.enabled = sprite != null;
}
}
private void BindNodes()
{
rectTransform = transform as RectTransform;
unityButton = GetComponent<Button>();
canvasGroup = GetComponent<CanvasGroup>();
icon = FindImage("Icon");
cooldownMask = FindImage("CooldownMask");
cooldownText = FindText("CooldownText");
chargeText = FindText("ChargeText");
Transform aim = FindRoleTransform("AimIndicator");
if (aim == null)
{
aim = FindRoleTransform("DragArrow");
}
aimIndicator = aim != null ? aim.gameObject : null;
SetAimVisible(false);
}
private Image FindImage(string childName)
{
Transform child = FindRoleTransform(childName);
return child != null ? child.GetComponent<Image>() : null;
}
private TMP_Text FindText(string childName)
{
Transform child = FindRoleTransform(childName);
return child != null ? child.GetComponent<TMP_Text>() : null;
}
private Transform FindRoleTransform(string roleName)
{
Transform child = transform.FindTransform(roleName);
if (child != null)
{
return child;
}
string suffix = "_" + roleName;
return FindDescendantEndingWith(transform, suffix);
}
private static Transform FindDescendantEndingWith(Transform root, string suffix)
{
if (root == null)
{
return null;
}
for (int i = 0; i < root.childCount; i++)
{
Transform child = root.GetChild(i);
if (child.name.EndsWith(suffix, StringComparison.Ordinal))
{
return child;
}
Transform nested = FindDescendantEndingWith(child, suffix);
if (nested != null)
{
return nested;
}
}
return null;
}
private void HandlePointerDown(Vector2 screenPosition, int pointerId, float now)
{
if (pointerDown)
{
return;
}
if (!IsInteractable)
{
Emit(MakeEvent(ActionButtonPhase.Blocked, screenPosition, pointerId, now));
return;
}
pointerDown = true;
activePointerId = pointerId;
pointerDownTime = now;
nextHoldTickTime = now + holdThresholdSeconds + holdTickIntervalSeconds;
holdStarted = false;
lastScreenPosition = screenPosition;
lastDragEvent = MakeEvent(ActionButtonPhase.Down, screenPosition, pointerId, now);
SetAimVisible(false);
Emit(lastDragEvent);
}
private void HandleDrag(Vector2 screenPosition, int pointerId, float now)
{
if (!pointerDown || pointerId != activePointerId)
{
return;
}
lastScreenPosition = screenPosition;
MaybeEmitHold(now, pointerId, screenPosition);
lastDragEvent = MakeEvent(ActionButtonPhase.Drag, screenPosition, pointerId, now);
SetAimVisible(true);
Emit(lastDragEvent);
}
private void HandlePointerUp(Vector2 screenPosition, int pointerId, float now)
{
if (!pointerDown || pointerId != activePointerId)
{
return;
}
MaybeEmitHold(now, pointerId, screenPosition);
if (holdStarted)
{
Emit(MakeEvent(ActionButtonPhase.HoldEnd, screenPosition, pointerId, now));
}
ActionButtonEvent finalEvent = MakeEvent(GetReleasePhase(screenPosition), screenPosition, pointerId, now);
Emit(finalEvent);
ResetPointerState();
SetAimVisible(false);
}
private void MaybeEmitHold(float now, int pointerId, Vector2 screenPosition)
{
if (!holdStarted && now - pointerDownTime >= holdThresholdSeconds)
{
holdStarted = true;
Emit(MakeEvent(ActionButtonPhase.HoldStart, screenPosition, pointerId, now));
}
if (!holdStarted)
{
return;
}
while (now >= nextHoldTickTime)
{
Emit(MakeEvent(ActionButtonPhase.HoldTick, screenPosition, pointerId, nextHoldTickTime));
nextHoldTickTime += holdTickIntervalSeconds;
}
}
private ActionButtonPhase GetReleasePhase(Vector2 screenPosition)
{
if (IsInCancelArea(screenPosition))
{
return ActionButtonPhase.Cancel;
}
return MakeEvent(ActionButtonPhase.Release, screenPosition, activePointerId, Time.unscaledTime).Distance01 >= releaseDistanceThreshold01
? ActionButtonPhase.Release
: ActionButtonPhase.Tap;
}
private ActionButtonEvent MakeEvent(ActionButtonPhase phase, Vector2 screenPosition, int pointerId, float now)
{
Vector2 localVector = screenPosition - GetScreenCenter();
float radius = GetScreenRadius() * maxDragRadiusMultiplier;
float distance01 = Mathf.Clamp01(localVector.magnitude / Mathf.Max(1f, radius));
Vector2 direction = localVector.sqrMagnitude > 0.000001f ? localVector.normalized : Vector2.zero;
bool canceled = phase == ActionButtonPhase.Cancel || IsInCancelArea(screenPosition);
return new ActionButtonEvent(
ButtonId,
phase,
screenPosition,
localVector,
direction,
distance01,
Mathf.Max(0f, now - pointerDownTime),
canceled,
pointerId);
}
private bool IsInCancelArea(Vector2 screenPosition)
{
if (cancelArea == null)
{
return false;
}
return RectTransformUtility.RectangleContainsScreenPoint(cancelArea, screenPosition, GetUICamera(cancelArea));
}
private void RefreshVisuals()
{
bool interactable = IsInteractable;
if (unityButton != null)
{
unityButton.interactable = interactable;
}
if (canvasGroup != null)
{
canvasGroup.alpha = interactable ? 1f : 0.55f;
canvasGroup.interactable = interactable;
}
if (cooldownMask != null)
{
cooldownMask.fillAmount = cooldownDuration > 0f ? Mathf.Clamp01(cooldownRemaining / cooldownDuration) : 0f;
cooldownMask.gameObject.SetActive(cooldownRemaining > 0f);
}
if (cooldownText != null)
{
cooldownText.text = cooldownRemaining > 0f ? Mathf.CeilToInt(cooldownRemaining).ToString() : string.Empty;
cooldownText.gameObject.SetActive(cooldownRemaining > 0f);
}
if (chargeText != null)
{
chargeText.text = charges >= 0 ? charges.ToString() : string.Empty;
chargeText.gameObject.SetActive(charges >= 0);
}
}
private void SetAimVisible(bool visible)
{
if (aimIndicator != null)
{
aimIndicator.SetActive(visible);
}
}
private void ResetPointerState()
{
pointerDown = false;
activePointerId = int.MinValue;
holdStarted = false;
lastScreenPosition = Vector2.zero;
lastDragEvent = default;
}
private void CancelActivePointer(float now)
{
if (!pointerDown)
{
ResetPointerState();
return;
}
MaybeEmitHold(now, activePointerId, lastScreenPosition);
if (holdStarted)
{
Emit(MakeEvent(ActionButtonPhase.HoldEnd, lastScreenPosition, activePointerId, now));
}
Emit(MakeEvent(ActionButtonPhase.Cancel, lastScreenPosition, activePointerId, now));
ResetPointerState();
}
private void Emit(ActionButtonEvent evt)
{
ButtonEvent?.Invoke(evt);
}
private Vector2 GetScreenCenter()
{
if (screenCenterOverride.HasValue)
{
return screenCenterOverride.Value;
}
RectTransform rect = rectTransform != null ? rectTransform : transform as RectTransform;
return rect != null ? RectTransformUtility.WorldToScreenPoint(GetUICamera(rect), rect.position) : Vector2.zero;
}
private float GetScreenRadius()
{
if (screenRadiusOverride.HasValue)
{
return Mathf.Max(1f, screenRadiusOverride.Value);
}
RectTransform rect = rectTransform != null ? rectTransform : transform as RectTransform;
if (rect == null)
{
return 1f;
}
Vector3[] corners = new Vector3[4];
rect.GetWorldCorners(corners);
Camera cam = GetUICamera(rect);
Vector2 s0 = RectTransformUtility.WorldToScreenPoint(cam, corners[0]);
Vector2 s2 = RectTransformUtility.WorldToScreenPoint(cam, corners[2]);
return Mathf.Max(1f, Mathf.Min(Mathf.Abs(s2.x - s0.x), Mathf.Abs(s2.y - s0.y)) * 0.5f);
}
private Camera GetUICamera(RectTransform rect)
{
Canvas canvas = rect != null ? rect.GetComponentInParent<Canvas>() : null;
if (canvas == null)
{
return null;
}
canvas = canvas.rootCanvas;
return canvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : canvas.worldCamera;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5e719656ace242789c22b78b47abcc66
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace XGame.VirtualInput
{
public sealed class VirtualActionPad : MonoBehaviour
{
private readonly Dictionary<ActionButtonId, VirtualActionButton> buttons =
new Dictionary<ActionButtonId, VirtualActionButton>();
private RectTransform cancelArea;
public event Action<ActionButtonEvent> ButtonEvent;
private void Awake()
{
BindNodes();
}
private void OnDestroy()
{
foreach (VirtualActionButton button in buttons.Values)
{
if (button != null)
{
button.ButtonEvent -= OnChildButtonEvent;
}
}
buttons.Clear();
}
public VirtualActionButton GetButton(ActionButtonId id)
{
buttons.TryGetValue(id, out VirtualActionButton button);
return button;
}
public void SetCooldown(ActionButtonId id, float remainingSeconds, float durationSeconds)
{
VirtualActionButton button = GetButton(id);
if (button != null)
{
button.SetCooldown(remainingSeconds, durationSeconds);
}
}
public void SetCharges(ActionButtonId id, int count)
{
VirtualActionButton button = GetButton(id);
if (button != null)
{
button.SetCharges(count);
}
}
public void SetButtonEnabled(ActionButtonId id, bool enabled)
{
VirtualActionButton button = GetButton(id);
if (button != null)
{
button.SetButtonEnabled(enabled);
}
}
public void SetCancelAreaVisible(bool visible)
{
if (cancelArea != null)
{
cancelArea.gameObject.SetActive(visible);
}
}
private void BindNodes()
{
Transform cancel = transform.FindTransform("CancelArea");
cancelArea = cancel as RectTransform;
RegisterButton(ActionButtonId.Primary, "Btn_Primary");
RegisterButton(ActionButtonId.Skill1, "Btn_Skill1");
RegisterButton(ActionButtonId.Skill2, "Btn_Skill2");
RegisterButton(ActionButtonId.Skill3, "Btn_Skill3");
RegisterButton(ActionButtonId.Skill4, "Btn_Skill4");
SetCancelAreaVisible(false);
}
private void RegisterButton(ActionButtonId id, string childName)
{
Transform child = transform.FindTransform(childName);
if (child == null)
{
Debug.LogWarning("[VirtualActionPad] missing child button: " + childName, this);
return;
}
VirtualActionButton button = child.GetComponent<VirtualActionButton>();
if (button == null)
{
button = child.gameObject.AddComponent<VirtualActionButton>();
}
button.ButtonId = id;
button.SetCancelArea(cancelArea);
button.ButtonEvent -= OnChildButtonEvent;
button.ButtonEvent += OnChildButtonEvent;
buttons[id] = button;
}
private void OnChildButtonEvent(ActionButtonEvent evt)
{
if (evt.Phase == ActionButtonPhase.Drag || evt.Phase == ActionButtonPhase.HoldStart)
{
SetCancelAreaVisible(cancelArea != null);
}
else if (evt.Phase == ActionButtonPhase.Release || evt.Phase == ActionButtonPhase.Tap || evt.Phase == ActionButtonPhase.Cancel || evt.Phase == ActionButtonPhase.Blocked)
{
SetCancelAreaVisible(false);
}
ButtonEvent?.Invoke(evt);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7c5500d410634275b54631c131475f3d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,273 @@
using System;
using UnityEngine;
using UnityEngine.EventSystems;
namespace XGame.VirtualInput
{
public sealed class VirtualJoystick : MonoBehaviour
{
[SerializeField] private RectTransform joystickRoot;
[SerializeField] private RectTransform knob;
[SerializeField] private float deadZone = 0.18f;
[SerializeField] private float acquireRadiusMultiplier = 1.6f;
private Vector2 value;
private bool isActive;
private bool isCaptured;
private bool sentStarted;
private int touchFingerId = -1;
private Vector2? screenCenterOverride;
private float? screenRadiusOverride;
public Vector2 Value => value;
public bool IsActive => isActive;
public event Action Started;
public event Action<Vector2, bool> Changed;
public event Action Ended;
private void Awake()
{
BindNodes();
}
private void OnDisable()
{
ReleasePointer();
}
private void Update()
{
if (!isActiveAndEnabled)
{
return;
}
if (UnityEngine.Input.touchCount > 0)
{
UpdateTouchInput();
return;
}
touchFingerId = -1;
UpdateMouseInput();
}
public bool IsScreenPositionInAcquireArea(Vector2 screenPosition)
{
return Vector2.Distance(screenPosition, GetScreenCenter()) <= GetScreenRadius() * acquireRadiusMultiplier;
}
private void BindNodes()
{
if (joystickRoot == null)
{
Transform widget = transform.Find("Joystick");
joystickRoot = widget as RectTransform;
}
if (joystickRoot == null)
{
joystickRoot = transform as RectTransform;
}
if (knob == null)
{
Transform knobTransform = transform.FindTransform("Knob");
knob = knobTransform as RectTransform;
}
if (knob == null)
{
Debug.LogError("[VirtualJoystick] missing Knob node: " + name, this);
enabled = false;
}
}
private void UpdateMouseInput()
{
if (UnityEngine.Input.GetMouseButtonDown(0))
{
isCaptured = IsScreenPositionInAcquireArea(UnityEngine.Input.mousePosition);
}
if (UnityEngine.Input.GetMouseButtonUp(0))
{
isCaptured = false;
ReleasePointer();
return;
}
if (isCaptured && UnityEngine.Input.GetMouseButton(0))
{
ApplyPointer(UnityEngine.Input.mousePosition);
}
else
{
ReleasePointer();
}
}
private void UpdateTouchInput()
{
if (touchFingerId >= 0)
{
for (int i = 0; i < UnityEngine.Input.touchCount; i++)
{
Touch touch = UnityEngine.Input.GetTouch(i);
if (touch.fingerId != touchFingerId)
{
continue;
}
if (touch.phase == TouchPhase.Ended || touch.phase == TouchPhase.Canceled)
{
touchFingerId = -1;
ReleasePointer();
return;
}
ApplyPointer(touch.position);
return;
}
touchFingerId = -1;
ReleasePointer();
return;
}
for (int i = 0; i < UnityEngine.Input.touchCount; i++)
{
Touch touch = UnityEngine.Input.GetTouch(i);
if (touch.phase == TouchPhase.Ended || touch.phase == TouchPhase.Canceled)
{
continue;
}
if (IsScreenPositionInAcquireArea(touch.position))
{
touchFingerId = touch.fingerId;
ApplyPointer(touch.position);
return;
}
}
ReleasePointer();
}
private void ApplyPointer(Vector2 screenPosition)
{
if (!sentStarted)
{
sentStarted = true;
Started?.Invoke();
}
Vector2 raw = (screenPosition - GetScreenCenter()) / GetScreenRadius();
Vector2 nextValue = Vector2.ClampMagnitude(raw, 1f);
bool nextActive = nextValue.magnitude >= deadZone;
if (!nextActive)
{
nextValue = Vector2.zero;
}
bool changed = (nextValue - value).sqrMagnitude > 0.000001f || nextActive != isActive;
value = nextValue;
isActive = nextActive;
UpdateKnob();
if (changed)
{
Changed?.Invoke(value, isActive);
}
}
private void ReleasePointer()
{
bool hadState = sentStarted || isActive || value.sqrMagnitude > 0.000001f;
sentStarted = false;
isCaptured = false;
touchFingerId = -1;
value = Vector2.zero;
isActive = false;
UpdateKnob();
if (hadState)
{
Changed?.Invoke(value, isActive);
Ended?.Invoke();
}
}
private void UpdateKnob()
{
if (knob != null)
{
knob.anchoredPosition = value * GetKnobTravelRadius();
}
}
private float GetKnobTravelRadius()
{
RectTransform root = joystickRoot != null ? joystickRoot : transform as RectTransform;
if (root == null)
{
return GetScreenRadius();
}
float rootHalf = Mathf.Min(root.rect.width, root.rect.height) * 0.5f;
float knobHalf = knob != null ? Mathf.Min(knob.rect.width, knob.rect.height) * 0.5f : 0f;
return Mathf.Max(1f, rootHalf - knobHalf);
}
private Vector2 GetScreenCenter()
{
if (screenCenterOverride.HasValue)
{
return screenCenterOverride.Value;
}
RectTransform root = joystickRoot != null ? joystickRoot : transform as RectTransform;
if (root == null)
{
return Vector2.zero;
}
return RectTransformUtility.WorldToScreenPoint(GetUICamera(), root.position);
}
private float GetScreenRadius()
{
if (screenRadiusOverride.HasValue)
{
return Mathf.Max(1f, screenRadiusOverride.Value);
}
RectTransform root = joystickRoot != null ? joystickRoot : transform as RectTransform;
if (root == null)
{
return 1f;
}
Camera cam = GetUICamera();
Vector3[] corners = new Vector3[4];
root.GetWorldCorners(corners);
Vector2 s0 = RectTransformUtility.WorldToScreenPoint(cam, corners[0]);
Vector2 s2 = RectTransformUtility.WorldToScreenPoint(cam, corners[2]);
return Mathf.Max(1f, Mathf.Min(Mathf.Abs(s2.x - s0.x), Mathf.Abs(s2.y - s0.y)) * 0.5f);
}
private Camera GetUICamera()
{
RectTransform root = joystickRoot != null ? joystickRoot : transform as RectTransform;
Canvas canvas = root != null ? root.GetComponentInParent<Canvas>() : null;
if (canvas == null)
{
return null;
}
canvas = canvas.rootCanvas;
return canvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : canvas.worldCamera;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 014f26c5eef24321b81f131f41a05a1d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,443 @@
{
"schemaVersion": 1,
"prefabName": "UI_VirtualActionPad",
"prefabPath": "Assets/Game/Art/UI/Prefab/UI_VirtualActionPad.prefab",
"description": "Reusable MOBA-style action pad with one primary button and four secondary buttons. Runtime consumers subscribe to VirtualActionPad pure input events.",
"canvas": {
"canvasScaler": {
"referenceResolution": [2048, 1024],
"matchWidthOrHeight": 0.5
}
},
"assets": {
"sprites": {
"btn_primary_normal": "Assets/Game/Art/UI/Texture/Common/btn_primary_normal.png",
"btn_primary_hover": "Assets/Game/Art/UI/Texture/Common/btn_primary_hover.png",
"btn_primary_pressed": "Assets/Game/Art/UI/Texture/Common/btn_primary_pressed.png",
"btn_primary_disabled": "Assets/Game/Art/UI/Texture/Common/btn_primary_disabled.png",
"btn_small_normal": "Assets/Game/Art/UI/Texture/Common/btn_small_normal.png",
"btn_small_hover": "Assets/Game/Art/UI/Texture/Common/btn_small_hover.png",
"btn_small_pressed": "Assets/Game/Art/UI/Texture/Common/btn_small_pressed.png",
"btn_small_disabled": "Assets/Game/Art/UI/Texture/Common/btn_small_disabled.png",
"ico_sword": "Assets/Game/Art/UI/Texture/Common/ico_sword.png",
"ico_shield": "Assets/Game/Art/UI/Texture/Common/ico_shield.png",
"ico_star": "Assets/Game/Art/UI/Texture/Common/ico_star.png",
"ico_heart": "Assets/Game/Art/UI/Texture/Common/ico_heart.png",
"ico_item": "Assets/Game/Art/UI/Texture/Common/ico_item.png",
"ui_overlay_dim": "Assets/Game/Art/UI/Texture/Common/ui_overlay_dim.png"
}
},
"nodes": [
{
"name": "UI_VirtualActionPad",
"parent": null,
"active": true,
"rect": {
"anchorMin": [1, 0],
"anchorMax": [1, 0],
"pivot": [1, 0],
"anchoredPosition": [-68, 72],
"sizeDelta": [500, 380]
},
"components": [
{
"type": "CanvasGroup",
"alpha": 0.98,
"interactable": true,
"blocksRaycasts": true
},
{
"type": "MonoBehaviour",
"typeName": "XGame.VirtualInput.VirtualActionPad"
}
]
},
{
"name": "CancelArea",
"parent": "UI_VirtualActionPad",
"active": false,
"rect": {
"anchorMin": [0.5, 1],
"anchorMax": [0.5, 1],
"pivot": [0.5, 0.5],
"anchoredPosition": [-110, -42],
"sizeDelta": [172, 82]
},
"components": [
{
"type": "Image",
"sprite": "ui_overlay_dim",
"imageType": "Sliced",
"preserveAspect": false,
"raycastTarget": false,
"color": "#FF5B5BCC"
}
]
},
{
"name": "CancelArea_Label",
"parent": "CancelArea",
"active": true,
"rect": {
"anchorMin": [0, 0],
"anchorMax": [1, 1],
"pivot": [0.5, 0.5],
"anchoredPosition": [0, 0],
"sizeDelta": [0, 0]
},
"components": [
{
"type": "TextMeshProUGUI",
"text": "Cancel",
"fontSize": 26,
"fontStyle": "Bold",
"alignment": "Center",
"color": "#FFFFFFFF",
"raycastTarget": false
}
]
},
{
"name": "Btn_Primary",
"parent": "UI_VirtualActionPad",
"active": true,
"rect": {
"anchorMin": [1, 0],
"anchorMax": [1, 0],
"pivot": [0.5, 0.5],
"anchoredPosition": [-96, 96],
"sizeDelta": [132, 132]
},
"components": [
{
"type": "Image",
"sprite": "btn_primary_normal",
"imageType": "Sliced",
"preserveAspect": false,
"raycastTarget": true,
"color": "#FFFFFFFF"
},
{
"type": "Button",
"transition": "SpriteSwap",
"spriteHighlighted": "btn_primary_hover",
"spritePressed": "btn_primary_pressed",
"spriteDisabled": "btn_primary_disabled"
},
{
"type": "MonoBehaviour",
"typeName": "XGame.VirtualInput.VirtualActionButton"
}
]
},
{
"name": "Btn_Skill1",
"parent": "UI_VirtualActionPad",
"active": true,
"rect": {
"anchorMin": [1, 0],
"anchorMax": [1, 0],
"pivot": [0.5, 0.5],
"anchoredPosition": [-244, 94],
"sizeDelta": [104, 104]
},
"components": [
{
"type": "Image",
"sprite": "btn_small_normal",
"imageType": "Sliced",
"preserveAspect": false,
"raycastTarget": true,
"color": "#FFFFFFFF"
},
{
"type": "Button",
"transition": "SpriteSwap",
"spriteHighlighted": "btn_small_hover",
"spritePressed": "btn_small_pressed",
"spriteDisabled": "btn_small_disabled"
},
{
"type": "MonoBehaviour",
"typeName": "XGame.VirtualInput.VirtualActionButton"
}
]
},
{
"name": "Btn_Skill2",
"parent": "UI_VirtualActionPad",
"active": true,
"rect": {
"anchorMin": [1, 0],
"anchorMax": [1, 0],
"pivot": [0.5, 0.5],
"anchoredPosition": [-202, 220],
"sizeDelta": [104, 104]
},
"components": [
{
"type": "Image",
"sprite": "btn_small_normal",
"imageType": "Sliced",
"preserveAspect": false,
"raycastTarget": true,
"color": "#FFFFFFFF"
},
{
"type": "Button",
"transition": "SpriteSwap",
"spriteHighlighted": "btn_small_hover",
"spritePressed": "btn_small_pressed",
"spriteDisabled": "btn_small_disabled"
},
{
"type": "MonoBehaviour",
"typeName": "XGame.VirtualInput.VirtualActionButton"
}
]
},
{
"name": "Btn_Skill3",
"parent": "UI_VirtualActionPad",
"active": true,
"rect": {
"anchorMin": [1, 0],
"anchorMax": [1, 0],
"pivot": [0.5, 0.5],
"anchoredPosition": [-78, 242],
"sizeDelta": [104, 104]
},
"components": [
{
"type": "Image",
"sprite": "btn_small_normal",
"imageType": "Sliced",
"preserveAspect": false,
"raycastTarget": true,
"color": "#FFFFFFFF"
},
{
"type": "Button",
"transition": "SpriteSwap",
"spriteHighlighted": "btn_small_hover",
"spritePressed": "btn_small_pressed",
"spriteDisabled": "btn_small_disabled"
},
{
"type": "MonoBehaviour",
"typeName": "XGame.VirtualInput.VirtualActionButton"
}
]
},
{
"name": "Btn_Skill4",
"parent": "UI_VirtualActionPad",
"active": true,
"rect": {
"anchorMin": [1, 0],
"anchorMax": [1, 0],
"pivot": [0.5, 0.5],
"anchoredPosition": [-328, 210],
"sizeDelta": [88, 88]
},
"components": [
{
"type": "Image",
"sprite": "btn_small_normal",
"imageType": "Sliced",
"preserveAspect": false,
"raycastTarget": true,
"color": "#FFFFFFFF"
},
{
"type": "Button",
"transition": "SpriteSwap",
"spriteHighlighted": "btn_small_hover",
"spritePressed": "btn_small_pressed",
"spriteDisabled": "btn_small_disabled"
},
{
"type": "MonoBehaviour",
"typeName": "XGame.VirtualInput.VirtualActionButton"
}
]
},
{
"name": "Primary_Icon",
"parent": "Btn_Primary",
"active": true,
"rect": { "anchorMin": [0.5, 0.5], "anchorMax": [0.5, 0.5], "pivot": [0.5, 0.5], "anchoredPosition": [0, 4], "sizeDelta": [72, 72] },
"components": [{ "type": "Image", "sprite": "ico_sword", "imageType": "Simple", "preserveAspect": true, "raycastTarget": false, "color": "#FFFFFFFF" }]
},
{
"name": "Skill1_Icon",
"parent": "Btn_Skill1",
"active": true,
"rect": { "anchorMin": [0.5, 0.5], "anchorMax": [0.5, 0.5], "pivot": [0.5, 0.5], "anchoredPosition": [0, 4], "sizeDelta": [56, 56] },
"components": [{ "type": "Image", "sprite": "ico_shield", "imageType": "Simple", "preserveAspect": true, "raycastTarget": false, "color": "#FFFFFFFF" }]
},
{
"name": "Skill2_Icon",
"parent": "Btn_Skill2",
"active": true,
"rect": { "anchorMin": [0.5, 0.5], "anchorMax": [0.5, 0.5], "pivot": [0.5, 0.5], "anchoredPosition": [0, 4], "sizeDelta": [56, 56] },
"components": [{ "type": "Image", "sprite": "ico_star", "imageType": "Simple", "preserveAspect": true, "raycastTarget": false, "color": "#FFFFFFFF" }]
},
{
"name": "Skill3_Icon",
"parent": "Btn_Skill3",
"active": true,
"rect": { "anchorMin": [0.5, 0.5], "anchorMax": [0.5, 0.5], "pivot": [0.5, 0.5], "anchoredPosition": [0, 4], "sizeDelta": [56, 56] },
"components": [{ "type": "Image", "sprite": "ico_heart", "imageType": "Simple", "preserveAspect": true, "raycastTarget": false, "color": "#FFFFFFFF" }]
},
{
"name": "Skill4_Icon",
"parent": "Btn_Skill4",
"active": true,
"rect": { "anchorMin": [0.5, 0.5], "anchorMax": [0.5, 0.5], "pivot": [0.5, 0.5], "anchoredPosition": [0, 3], "sizeDelta": [48, 48] },
"components": [{ "type": "Image", "sprite": "ico_item", "imageType": "Simple", "preserveAspect": true, "raycastTarget": false, "color": "#FFFFFFFF" }]
},
{
"name": "Primary_CooldownMask",
"parent": "Btn_Primary",
"active": false,
"rect": { "anchorMin": [0, 0], "anchorMax": [1, 1], "pivot": [0.5, 0.5], "anchoredPosition": [0, 0], "sizeDelta": [0, 0] },
"components": [{ "type": "Image", "sprite": "ui_overlay_dim", "imageType": "Sliced", "preserveAspect": false, "raycastTarget": false, "color": "#0B1724AA" }]
},
{
"name": "Skill1_CooldownMask",
"parent": "Btn_Skill1",
"active": false,
"rect": { "anchorMin": [0, 0], "anchorMax": [1, 1], "pivot": [0.5, 0.5], "anchoredPosition": [0, 0], "sizeDelta": [0, 0] },
"components": [{ "type": "Image", "sprite": "ui_overlay_dim", "imageType": "Sliced", "preserveAspect": false, "raycastTarget": false, "color": "#0B1724AA" }]
},
{
"name": "Skill2_CooldownMask",
"parent": "Btn_Skill2",
"active": false,
"rect": { "anchorMin": [0, 0], "anchorMax": [1, 1], "pivot": [0.5, 0.5], "anchoredPosition": [0, 0], "sizeDelta": [0, 0] },
"components": [{ "type": "Image", "sprite": "ui_overlay_dim", "imageType": "Sliced", "preserveAspect": false, "raycastTarget": false, "color": "#0B1724AA" }]
},
{
"name": "Skill3_CooldownMask",
"parent": "Btn_Skill3",
"active": false,
"rect": { "anchorMin": [0, 0], "anchorMax": [1, 1], "pivot": [0.5, 0.5], "anchoredPosition": [0, 0], "sizeDelta": [0, 0] },
"components": [{ "type": "Image", "sprite": "ui_overlay_dim", "imageType": "Sliced", "preserveAspect": false, "raycastTarget": false, "color": "#0B1724AA" }]
},
{
"name": "Skill4_CooldownMask",
"parent": "Btn_Skill4",
"active": false,
"rect": { "anchorMin": [0, 0], "anchorMax": [1, 1], "pivot": [0.5, 0.5], "anchoredPosition": [0, 0], "sizeDelta": [0, 0] },
"components": [{ "type": "Image", "sprite": "ui_overlay_dim", "imageType": "Sliced", "preserveAspect": false, "raycastTarget": false, "color": "#0B1724AA" }]
},
{
"name": "Primary_CooldownText",
"parent": "Btn_Primary",
"active": false,
"rect": { "anchorMin": [0, 0], "anchorMax": [1, 1], "pivot": [0.5, 0.5], "anchoredPosition": [0, 0], "sizeDelta": [0, 0] },
"components": [{ "type": "TextMeshProUGUI", "text": "", "fontSize": 34, "fontStyle": "Bold", "alignment": "Center", "color": "#FFFFFFFF", "raycastTarget": false }]
},
{
"name": "Primary_ChargeText",
"parent": "Btn_Primary",
"active": false,
"rect": { "anchorMin": [1, 0], "anchorMax": [1, 0], "pivot": [1, 0], "anchoredPosition": [-8, 8], "sizeDelta": [42, 30] },
"components": [{ "type": "TextMeshProUGUI", "text": "", "fontSize": 24, "fontStyle": "Bold", "alignment": "Center", "color": "#FFE27AFF", "raycastTarget": false }]
},
{
"name": "Primary_AimIndicator",
"parent": "Btn_Primary",
"active": false,
"rect": { "anchorMin": [0.5, 0.5], "anchorMax": [0.5, 0.5], "pivot": [0.5, 0], "anchoredPosition": [0, 0], "sizeDelta": [12, 118] },
"components": [{ "type": "Image", "sprite": "ui_overlay_dim", "imageType": "Sliced", "preserveAspect": false, "raycastTarget": false, "color": "#43B7F5AA" }]
},
{
"name": "Skill1_CooldownText",
"parent": "Btn_Skill1",
"active": false,
"rect": { "anchorMin": [0, 0], "anchorMax": [1, 1], "pivot": [0.5, 0.5], "anchoredPosition": [0, 0], "sizeDelta": [0, 0] },
"components": [{ "type": "TextMeshProUGUI", "text": "", "fontSize": 28, "fontStyle": "Bold", "alignment": "Center", "color": "#FFFFFFFF", "raycastTarget": false }]
},
{
"name": "Skill1_ChargeText",
"parent": "Btn_Skill1",
"active": false,
"rect": { "anchorMin": [1, 0], "anchorMax": [1, 0], "pivot": [1, 0], "anchoredPosition": [-6, 6], "sizeDelta": [36, 26] },
"components": [{ "type": "TextMeshProUGUI", "text": "", "fontSize": 20, "fontStyle": "Bold", "alignment": "Center", "color": "#FFE27AFF", "raycastTarget": false }]
},
{
"name": "Skill1_AimIndicator",
"parent": "Btn_Skill1",
"active": false,
"rect": { "anchorMin": [0.5, 0.5], "anchorMax": [0.5, 0.5], "pivot": [0.5, 0], "anchoredPosition": [0, 0], "sizeDelta": [10, 98] },
"components": [{ "type": "Image", "sprite": "ui_overlay_dim", "imageType": "Sliced", "preserveAspect": false, "raycastTarget": false, "color": "#43B7F5AA" }]
},
{
"name": "Skill2_CooldownText",
"parent": "Btn_Skill2",
"active": false,
"rect": { "anchorMin": [0, 0], "anchorMax": [1, 1], "pivot": [0.5, 0.5], "anchoredPosition": [0, 0], "sizeDelta": [0, 0] },
"components": [{ "type": "TextMeshProUGUI", "text": "", "fontSize": 28, "fontStyle": "Bold", "alignment": "Center", "color": "#FFFFFFFF", "raycastTarget": false }]
},
{
"name": "Skill2_ChargeText",
"parent": "Btn_Skill2",
"active": false,
"rect": { "anchorMin": [1, 0], "anchorMax": [1, 0], "pivot": [1, 0], "anchoredPosition": [-6, 6], "sizeDelta": [36, 26] },
"components": [{ "type": "TextMeshProUGUI", "text": "", "fontSize": 20, "fontStyle": "Bold", "alignment": "Center", "color": "#FFE27AFF", "raycastTarget": false }]
},
{
"name": "Skill2_AimIndicator",
"parent": "Btn_Skill2",
"active": false,
"rect": { "anchorMin": [0.5, 0.5], "anchorMax": [0.5, 0.5], "pivot": [0.5, 0], "anchoredPosition": [0, 0], "sizeDelta": [10, 98] },
"components": [{ "type": "Image", "sprite": "ui_overlay_dim", "imageType": "Sliced", "preserveAspect": false, "raycastTarget": false, "color": "#43B7F5AA" }]
},
{
"name": "Skill3_CooldownText",
"parent": "Btn_Skill3",
"active": false,
"rect": { "anchorMin": [0, 0], "anchorMax": [1, 1], "pivot": [0.5, 0.5], "anchoredPosition": [0, 0], "sizeDelta": [0, 0] },
"components": [{ "type": "TextMeshProUGUI", "text": "", "fontSize": 28, "fontStyle": "Bold", "alignment": "Center", "color": "#FFFFFFFF", "raycastTarget": false }]
},
{
"name": "Skill3_ChargeText",
"parent": "Btn_Skill3",
"active": false,
"rect": { "anchorMin": [1, 0], "anchorMax": [1, 0], "pivot": [1, 0], "anchoredPosition": [-6, 6], "sizeDelta": [36, 26] },
"components": [{ "type": "TextMeshProUGUI", "text": "", "fontSize": 20, "fontStyle": "Bold", "alignment": "Center", "color": "#FFE27AFF", "raycastTarget": false }]
},
{
"name": "Skill3_AimIndicator",
"parent": "Btn_Skill3",
"active": false,
"rect": { "anchorMin": [0.5, 0.5], "anchorMax": [0.5, 0.5], "pivot": [0.5, 0], "anchoredPosition": [0, 0], "sizeDelta": [10, 98] },
"components": [{ "type": "Image", "sprite": "ui_overlay_dim", "imageType": "Sliced", "preserveAspect": false, "raycastTarget": false, "color": "#43B7F5AA" }]
},
{
"name": "Skill4_CooldownText",
"parent": "Btn_Skill4",
"active": false,
"rect": { "anchorMin": [0, 0], "anchorMax": [1, 1], "pivot": [0.5, 0.5], "anchoredPosition": [0, 0], "sizeDelta": [0, 0] },
"components": [{ "type": "TextMeshProUGUI", "text": "", "fontSize": 24, "fontStyle": "Bold", "alignment": "Center", "color": "#FFFFFFFF", "raycastTarget": false }]
},
{
"name": "Skill4_ChargeText",
"parent": "Btn_Skill4",
"active": false,
"rect": { "anchorMin": [1, 0], "anchorMax": [1, 0], "pivot": [1, 0], "anchoredPosition": [-5, 5], "sizeDelta": [32, 24] },
"components": [{ "type": "TextMeshProUGUI", "text": "", "fontSize": 18, "fontStyle": "Bold", "alignment": "Center", "color": "#FFE27AFF", "raycastTarget": false }]
},
{
"name": "Skill4_AimIndicator",
"parent": "Btn_Skill4",
"active": false,
"rect": { "anchorMin": [0.5, 0.5], "anchorMax": [0.5, 0.5], "pivot": [0.5, 0], "anchoredPosition": [0, 0], "sizeDelta": [8, 84] },
"components": [{ "type": "Image", "sprite": "ui_overlay_dim", "imageType": "Sliced", "preserveAspect": false, "raycastTarget": false, "color": "#43B7F5AA" }]
}
],
"bindings": [],
"events": []
}
+125
View File
@@ -0,0 +1,125 @@
{
"schemaVersion": 1,
"prefabName": "UI_VirtualJoystick",
"prefabPath": "Assets/Game/Art/UI/Prefab/UI_VirtualJoystick.prefab",
"description": "Reusable virtual joystick HUD prefab for lobby and minigames. Runtime consumers subscribe to VirtualJoystick events and map pure direction input to their own behavior.",
"canvas": {
"canvasScaler": {
"referenceResolution": [2048, 1024],
"matchWidthOrHeight": 0.5
}
},
"assets": {
"sprites": {
"lobby_joystick_base": "Assets/Game/Art/UI/Texture/Common/lobby_joystick_base.png",
"lobby_joystick_ring": "Assets/Game/Art/UI/Texture/Common/lobby_joystick_ring.png",
"lobby_joystick_knob": "Assets/Game/Art/UI/Texture/Common/lobby_joystick_knob.png"
}
},
"nodes": [
{
"name": "UI_VirtualJoystick",
"parent": null,
"active": true,
"rect": {
"anchorMin": [0, 0],
"anchorMax": [0, 0],
"pivot": [0.5, 0.5],
"anchoredPosition": [260, 220],
"sizeDelta": [148, 148]
},
"components": [
{
"type": "CanvasGroup",
"alpha": 0.96,
"interactable": true,
"blocksRaycasts": true
},
{
"type": "MonoBehaviour",
"typeName": "XGame.VirtualInput.VirtualJoystick"
}
]
},
{
"name": "Base",
"parent": "UI_VirtualJoystick",
"active": true,
"rect": {
"anchorMin": [0.5, 0.5],
"anchorMax": [0.5, 0.5],
"pivot": [0.5, 0.5],
"anchoredPosition": [0, 0],
"sizeDelta": [148, 148]
},
"components": [
{
"type": "Image",
"sprite": "lobby_joystick_base",
"imageType": "Simple",
"preserveAspect": true,
"raycastTarget": false,
"color": "#FFFFFFFF"
},
{
"type": "Shadow",
"effectColor": "#06101F6B",
"effectDistance": [0, -7],
"useGraphicAlpha": true
}
]
},
{
"name": "Ring",
"parent": "UI_VirtualJoystick",
"active": true,
"rect": {
"anchorMin": [0.5, 0.5],
"anchorMax": [0.5, 0.5],
"pivot": [0.5, 0.5],
"anchoredPosition": [0, 0],
"sizeDelta": [148, 148]
},
"components": [
{
"type": "Image",
"sprite": "lobby_joystick_ring",
"imageType": "Simple",
"preserveAspect": true,
"raycastTarget": false,
"color": "#FFFFFFFF"
}
]
},
{
"name": "Knob",
"parent": "UI_VirtualJoystick",
"active": true,
"rect": {
"anchorMin": [0.5, 0.5],
"anchorMax": [0.5, 0.5],
"pivot": [0.5, 0.5],
"anchoredPosition": [0, 0],
"sizeDelta": [56, 56]
},
"components": [
{
"type": "Image",
"sprite": "lobby_joystick_knob",
"imageType": "Simple",
"preserveAspect": true,
"raycastTarget": false,
"color": "#FFFFFFFF"
},
{
"type": "Shadow",
"effectColor": "#06101F5C",
"effectDistance": [0, -5],
"useGraphicAlpha": true
}
]
}
],
"bindings": [],
"events": []
}
@@ -0,0 +1,334 @@
# Virtual HUD Input Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build reusable Unity HUD input components for a virtual joystick and a MOBA-style action button pad that emit pure input events for lobby and minigame consumers.
**Architecture:** Add focused runtime components under `Client/Assets/Script/xmain/Input/`: `VirtualJoystick`, `VirtualActionButton`, and `VirtualActionPad`. Add JSON prefab descriptions through the existing `Doc/UIPrefabCreater` pipeline, then migrate `LobbyWorldController` to consume `VirtualJoystick` instead of owning joystick sampling.
**Tech Stack:** Unity UGUI, EventSystem pointer interfaces, TextMeshPro, NUnit EditMode tests, existing JSON-to-prefab builder.
## Global Constraints
- Components emit pure input events and must not send network messages.
- First version uses current UGUI/EventSystem/StandaloneInputModule, not Unity's new Input System.
- First version reuses existing Common UI resources and does not require new MOBA circle button art.
- Existing minigame framework contracts must not change.
- Do not revert or overwrite unrelated working tree changes.
---
## File Structure
- Create `Client/Assets/Script/xmain/Input/VirtualJoystick.cs`: joystick sampling, pointer capture, dead zone, knob motion, and public direction events.
- Create `Client/Assets/Script/xmain/Input/VirtualActionButton.cs`: action button IDs, event payload, pointer state machine, cooldown/charge/enabled visual state.
- Create `Client/Assets/Script/xmain/Input/VirtualActionPad.cs`: five-button registry, event forwarding, and pad-level state API.
- Create `Client/Assets/Script/Tests/EditMode/VirtualJoystickTests.cs`: component math and state tests for joystick behavior.
- Create `Client/Assets/Script/Tests/EditMode/VirtualActionButtonTests.cs`: button state machine tests and visual state tests.
- Create `Client/Assets/Script/Tests/EditMode/VirtualActionPadTests.cs`: button discovery and event forwarding tests.
- Create `Doc/UIPrefabCreater/UI_VirtualJoystick.json`: reusable joystick prefab definition.
- Create `Doc/UIPrefabCreater/UI_VirtualActionPad.json`: reusable MOBA-style action pad prefab definition.
- Modify `Client/Assets/Script/xmain/Client/LobbyWorldController.cs`: load `UI_VirtualJoystick`, subscribe to events, remove duplicated joystick sampling methods, and use the component for camera exclusion.
---
### Task 1: VirtualJoystick Runtime Component
**Files:**
- Create: `Client/Assets/Script/xmain/Input/VirtualJoystick.cs`
- Test: `Client/Assets/Script/Tests/EditMode/VirtualJoystickTests.cs`
**Interfaces:**
- Produces: `XGame.Input.VirtualJoystick.Value`, `IsActive`, `Started`, `Changed`, `Ended`, `IsScreenPositionInAcquireArea(Vector2)`, `SetPointerForTest(Vector2?)`
- Consumes: Unity `RectTransform`, optional child `Knob`, UGUI canvas camera conversion.
- [ ] **Step 1: Write the failing tests**
Add tests that create a `VirtualJoystick`, assign a root and knob `RectTransform`, call `SetPointerForTest`, and assert dead-zone, clamping, release, and acquire-area behavior.
```csharp
[Test]
public void Joystick_DeadZoneReportsInactiveAndZero()
{
var fixture = CreateJoystick();
fixture.Joystick.SetPointerForTest(new Vector2(106f, 100f));
Assert.That(fixture.Joystick.IsActive, Is.False);
Assert.That(fixture.Joystick.Value, Is.EqualTo(Vector2.zero));
}
[Test]
public void Joystick_ClampsDirectionAndMovesKnobWithinTravel()
{
var fixture = CreateJoystick();
fixture.Joystick.SetPointerForTest(new Vector2(300f, 100f));
Assert.That(fixture.Joystick.IsActive, Is.True);
Assert.That(fixture.Joystick.Value.x, Is.EqualTo(1f).Within(0.001f));
Assert.That(fixture.Knob.anchoredPosition.x, Is.EqualTo(24f).Within(0.001f));
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: Unity EditMode tests for `VirtualJoystickTests`.
Expected: compile fails because `XGame.Input.VirtualJoystick` does not exist.
- [ ] **Step 3: Implement minimal component**
Implement `VirtualJoystick` with:
```csharp
public Vector2 Value { get; private set; }
public bool IsActive { get; private set; }
public event Action Started;
public event Action<Vector2, bool> Changed;
public event Action Ended;
public bool IsScreenPositionInAcquireArea(Vector2 screenPosition);
public void SetPointerForTest(Vector2? screenPosition);
```
It must bind `Knob` in `Awake`, compute screen center/radius from root or `Joystick` child, clamp values, apply dead zone, and reset on release.
- [ ] **Step 4: Run test to verify it passes**
Run: Unity EditMode tests for `VirtualJoystickTests`.
Expected: all `VirtualJoystickTests` pass.
---
### Task 2: VirtualActionButton Runtime Component
**Files:**
- Create: `Client/Assets/Script/xmain/Input/VirtualActionButton.cs`
- Test: `Client/Assets/Script/Tests/EditMode/VirtualActionButtonTests.cs`
**Interfaces:**
- Produces: `ActionButtonId`, `ActionButtonPhase`, `ActionButtonEvent`, `VirtualActionButton.ButtonEvent`, `SetCooldown`, `SetCharges`, `SetButtonEnabled`, `SetCancelArea`, `SetIcon`
- Consumes: Unity `IPointerDownHandler`, `IDragHandler`, `IPointerUpHandler`, optional `Icon`, `CooldownMask`, `CooldownText`, `ChargeText`, `AimIndicator` or `DragArrow`.
- [ ] **Step 1: Write the failing tests**
Add tests for tap, drag release, cancel, blocked cooldown, and charges.
```csharp
[Test]
public void Button_TapEmitsDownThenTap()
{
var button = CreateButton();
var events = Capture(button);
button.HandlePointerDownForTest(new Vector2(100f, 100f), 1, 0f);
button.HandlePointerUpForTest(new Vector2(102f, 100f), 1, 0.1f);
Assert.That(events.Select(e => e.Phase), Is.EqualTo(new[] { ActionButtonPhase.Down, ActionButtonPhase.Tap }));
}
[Test]
public void Button_CooldownEmitsBlockedOnly()
{
var button = CreateButton();
var events = Capture(button);
button.SetCooldown(3f, 5f);
button.HandlePointerDownForTest(new Vector2(100f, 100f), 1, 0f);
Assert.That(events.Single().Phase, Is.EqualTo(ActionButtonPhase.Blocked));
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: Unity EditMode tests for `VirtualActionButtonTests`.
Expected: compile fails because action button types do not exist.
- [ ] **Step 3: Implement minimal component**
Implement pointer interfaces and test helpers:
```csharp
public void HandlePointerDownForTest(Vector2 screenPosition, int pointerId, float now);
public void HandleDragForTest(Vector2 screenPosition, int pointerId, float now);
public void HandlePointerUpForTest(Vector2 screenPosition, int pointerId, float now);
```
Use a single active pointer, emit `Down`, `Drag`, `Tap`, `Release`, `Cancel`, `HoldStart`, `HoldTick`, `HoldEnd`, and `Blocked` according to the spec. Refresh cooldown mask fill, cooldown text, charge text, icon, and enabled/blocked visuals when matching nodes exist.
- [ ] **Step 4: Run test to verify it passes**
Run: Unity EditMode tests for `VirtualActionButtonTests`.
Expected: all `VirtualActionButtonTests` pass.
---
### Task 3: VirtualActionPad Runtime Component
**Files:**
- Create: `Client/Assets/Script/xmain/Input/VirtualActionPad.cs`
- Test: `Client/Assets/Script/Tests/EditMode/VirtualActionPadTests.cs`
**Interfaces:**
- Consumes: `VirtualActionButton`
- Produces: `GetButton(ActionButtonId)`, pad-level `ButtonEvent`, `SetCooldown`, `SetCharges`, `SetButtonEnabled`, `SetCancelAreaVisible`
- [ ] **Step 1: Write the failing tests**
Add tests that build five children named `Btn_Primary`, `Btn_Skill1`, `Btn_Skill2`, `Btn_Skill3`, and `Btn_Skill4`, attach buttons, call `Awake` through Unity lifecycle, and assert lookup/event forwarding.
```csharp
[Test]
public void Pad_FindsFiveButtonsAndForwardsEvents()
{
var pad = CreatePadWithButtons();
var events = new List<ActionButtonEvent>();
pad.ButtonEvent += events.Add;
pad.GetButton(ActionButtonId.Primary).HandlePointerDownForTest(new Vector2(100f, 100f), 1, 0f);
Assert.That(events.Single().ButtonId, Is.EqualTo(ActionButtonId.Primary));
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: Unity EditMode tests for `VirtualActionPadTests`.
Expected: compile fails because `VirtualActionPad` does not exist.
- [ ] **Step 3: Implement minimal component**
Bind child buttons by existing component or child name, assign stable IDs, set each button's cancel area, subscribe to child events, and forward state API calls.
- [ ] **Step 4: Run test to verify it passes**
Run: Unity EditMode tests for `VirtualActionPadTests`.
Expected: all `VirtualActionPadTests` pass.
---
### Task 4: JSON Prefab Descriptions
**Files:**
- Create: `Doc/UIPrefabCreater/UI_VirtualJoystick.json`
- Create: `Doc/UIPrefabCreater/UI_VirtualActionPad.json`
- Test: `Client/Assets/Script/Tests/EditMode/VirtualInputPrefabLayoutTests.cs`
**Interfaces:**
- Consumes: existing `JsonUIPrefabBuilder` component support for `Image`, `TextMeshProUGUI`, `CanvasGroup`, `Button`, and `MonoBehaviour`.
- Produces: prefab definitions that attach `XGame.Input.VirtualJoystick` and `XGame.Input.VirtualActionPad`.
- [ ] **Step 1: Write the failing prefab layout tests**
Test the JSON files exist, define expected root names, include required child node names, and use expected prefab paths.
```csharp
[Test]
public void VirtualActionPadJson_DefinesFiveButtonsAndCancelArea()
{
string json = File.ReadAllText("Doc/UIPrefabCreater/UI_VirtualActionPad.json");
Assert.That(json, Does.Contain("\"prefabName\": \"UI_VirtualActionPad\""));
Assert.That(json, Does.Contain("\"Btn_Primary\""));
Assert.That(json, Does.Contain("\"Btn_Skill4\""));
Assert.That(json, Does.Contain("\"CancelArea\""));
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: Unity EditMode tests for `VirtualInputPrefabLayoutTests`.
Expected: fail because JSON files do not exist.
- [ ] **Step 3: Add JSON files**
Add `UI_VirtualJoystick.json` from current `UI_LobbyJoystick.json` with generic names and `VirtualJoystick` component. Add `UI_VirtualActionPad.json` with the root, five buttons, cancel area, icon/cooldown/charge/aim child nodes, and `VirtualActionPad` component.
- [ ] **Step 4: Run test to verify it passes**
Run: Unity EditMode tests for `VirtualInputPrefabLayoutTests`.
Expected: all layout tests pass.
---
### Task 5: LobbyWorldController Migration
**Files:**
- Modify: `Client/Assets/Script/xmain/Client/LobbyWorldController.cs`
- Test: `Client/Assets/Script/Tests/EditMode/LobbyWorldControllerVirtualJoystickTests.cs`
**Interfaces:**
- Consumes: `XGame.Input.VirtualJoystick`
- Produces: lobby movement that uses joystick events instead of private joystick sampling.
- [ ] **Step 1: Write the failing integration test**
Use reflection or exposed test helpers to verify `LobbyWorldController` can accept a `VirtualJoystick` value and `GetMoveDirection` includes it. If direct runtime reflection is too brittle, add a focused test for a new internal helper that maps joystick vector plus camera vectors to world direction.
```csharp
[Test]
public void LobbyMovement_UsesVirtualJoystickVector()
{
Vector3 dir = LobbyWorldController.MapMoveInputForTest(
new Vector2(1f, 0f),
Vector3.forward,
Vector3.right,
false, false, false, false);
Assert.That(dir, Is.EqualTo(Vector3.right));
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: Unity EditMode tests for `LobbyWorldControllerVirtualJoystickTests`.
Expected: fail because helper or migration is not present.
- [ ] **Step 3: Migrate controller**
Change `JoystickPrefabPath` to `UI_VirtualJoystick.prefab`, replace joystick root/knob sampling fields with one `VirtualJoystick` field, subscribe to `Changed` and `Ended`, keep the current `joystick` and `joystickActive` cached values as business input, and remove duplicated pointer sampling helpers. Camera input uses `virtualJoystick.IsScreenPositionInAcquireArea(pos)` when available.
- [ ] **Step 4: Run test to verify it passes**
Run: Unity EditMode tests for `LobbyWorldControllerVirtualJoystickTests`.
Expected: integration test passes.
---
### Task 6: Verification
**Files:**
- All files changed in tasks 1-5.
**Interfaces:**
- Consumes: all implementation tasks.
- Produces: verified working tree ready for review.
- [ ] **Step 1: Run targeted EditMode tests**
Run all new test classes:
```text
VirtualJoystickTests
VirtualActionButtonTests
VirtualActionPadTests
VirtualInputPrefabLayoutTests
LobbyWorldControllerVirtualJoystickTests
```
Expected: all pass.
- [ ] **Step 2: Run broader existing affected tests**
Run existing tests that touch UI and stage isolation:
```text
MiniGameStageIsolationTests
UIManagerAttachTests
MatchWaitingPrefabLayoutTests
```
Expected: all pass.
- [ ] **Step 3: Inspect working tree**
Run: `git status --short`
Expected: new/modified files are limited to input components, tests, prefab JSON, plan/spec if updated, and `LobbyWorldController.cs`; unrelated existing Unity asset changes remain unstaged.
- [ ] **Step 4: Commit only related files if tests pass**
Stage only the files created or modified by this implementation and commit with:
```bash
git commit -m "feat: add virtual hud input components"
```
Expected: commit includes no unrelated actor/UI asset modifications.