Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ccfab3f2b | ||
|
|
5853c5bd83 | ||
|
|
3546d16632 |
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:
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
public static class HumanoidAnimationFbxImportSettings
|
||||||
|
{
|
||||||
|
public const string ActorRootPath = "Assets/Game/Art/Actor/";
|
||||||
|
public const string CityboyAvatarPath = "Assets/Game/Art/Actor/cityboy/cityboy_sk.fbx";
|
||||||
|
public const string CityboyAvatarName = "Cityboy_sk Avatar";
|
||||||
|
public const string CityboyHipBoneName = "mixamorig:Hips";
|
||||||
|
|
||||||
|
public static bool ShouldProcessAsset(string assetPath)
|
||||||
|
{
|
||||||
|
string normalizedPath = NormalizeAssetPath(assetPath);
|
||||||
|
|
||||||
|
return normalizedPath.EndsWith(".fbx", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& normalizedPath.StartsWith(ActorRootPath, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(normalizedPath, CityboyAvatarPath, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ModelImporterClipAnimation ApplyClipSettings(ModelImporterClipAnimation clip)
|
||||||
|
{
|
||||||
|
clip.lockRootRotation = true;
|
||||||
|
clip.keepOriginalOrientation = true;
|
||||||
|
clip.lockRootHeightY = true;
|
||||||
|
return clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ModelImporterClipAnimation[] ApplyClipSettings(ModelImporterClipAnimation[] clips)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < clips.Length; i++)
|
||||||
|
{
|
||||||
|
clips[i] = ApplyClipSettings(clips[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return clips;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool ShouldUseCityboyAvatar(string assetPath)
|
||||||
|
{
|
||||||
|
string filePath = ResolveAssetFilePath(assetPath);
|
||||||
|
if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ShouldUseCityboyAvatar(File.ReadAllBytes(filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool ShouldUseCityboyAvatar(byte[] assetBytes)
|
||||||
|
{
|
||||||
|
if (assetBytes == null || assetBytes.Length == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] marker = System.Text.Encoding.ASCII.GetBytes(CityboyHipBoneName);
|
||||||
|
for (int i = 0; i <= assetBytes.Length - marker.Length; i++)
|
||||||
|
{
|
||||||
|
bool matched = true;
|
||||||
|
for (int markerIndex = 0; markerIndex < marker.Length; markerIndex++)
|
||||||
|
{
|
||||||
|
if (assetBytes[i + markerIndex] != marker[markerIndex])
|
||||||
|
{
|
||||||
|
matched = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matched)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ModelImporterClipAnimation CreateClipAnimation(TakeInfo takeInfo)
|
||||||
|
{
|
||||||
|
string clipName = string.IsNullOrEmpty(takeInfo.defaultClipName) ? takeInfo.name : takeInfo.defaultClipName;
|
||||||
|
|
||||||
|
return ApplyClipSettings(new ModelImporterClipAnimation
|
||||||
|
{
|
||||||
|
name = clipName,
|
||||||
|
takeName = takeInfo.name,
|
||||||
|
firstFrame = takeInfo.startTime * takeInfo.sampleRate,
|
||||||
|
lastFrame = takeInfo.stopTime * takeInfo.sampleRate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ModelImporterClipAnimation CreateClipAnimation(AnimationClip animationClip)
|
||||||
|
{
|
||||||
|
float frameRate = animationClip.frameRate > 0f ? animationClip.frameRate : 30f;
|
||||||
|
|
||||||
|
return ApplyClipSettings(new ModelImporterClipAnimation
|
||||||
|
{
|
||||||
|
name = animationClip.name,
|
||||||
|
takeName = animationClip.name,
|
||||||
|
firstFrame = 0f,
|
||||||
|
lastFrame = animationClip.length * frameRate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeAssetPath(string assetPath)
|
||||||
|
{
|
||||||
|
return string.IsNullOrEmpty(assetPath) ? string.Empty : assetPath.Replace('\\', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveAssetFilePath(string assetPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(assetPath))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Path.IsPathRooted(assetPath) || File.Exists(assetPath))
|
||||||
|
{
|
||||||
|
return assetPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
DirectoryInfo projectRoot = Directory.GetParent(Application.dataPath);
|
||||||
|
return projectRoot == null ? assetPath : Path.Combine(projectRoot.FullName, NormalizeAssetPath(assetPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HumanoidAnimationFbxPostprocessor : AssetPostprocessor
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> ReimportingAssets = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private void OnPreprocessModel()
|
||||||
|
{
|
||||||
|
if (!(assetImporter is ModelImporter modelImporter))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HumanoidAnimationFbxImportSettings.ShouldProcessAsset(assetPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modelImporter.importAnimation = true;
|
||||||
|
modelImporter.animationType = ModelImporterAnimationType.Human;
|
||||||
|
if (HumanoidAnimationFbxImportSettings.ShouldUseCityboyAvatar(assetPath))
|
||||||
|
{
|
||||||
|
Avatar cityboyAvatar = LoadCityboyAvatar();
|
||||||
|
if (cityboyAvatar == null)
|
||||||
|
{
|
||||||
|
Debug.LogWarning(
|
||||||
|
$"[HumanoidAnimationFbxPostprocessor] Cannot find Cityboy avatar at {HumanoidAnimationFbxImportSettings.CityboyAvatarPath}. Skip import settings for {assetPath}.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modelImporter.avatarSetup = ModelImporterAvatarSetup.CopyFromOther;
|
||||||
|
modelImporter.sourceAvatar = cityboyAvatar;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
modelImporter.avatarSetup = ModelImporterAvatarSetup.CreateFromThisModel;
|
||||||
|
modelImporter.sourceAvatar = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ModelImporterClipAnimation[] clips = GetAnimationClips(modelImporter);
|
||||||
|
if (clips.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modelImporter.clipAnimations = HumanoidAnimationFbxImportSettings.ApplyClipSettings(clips);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnPostprocessAllAssets(
|
||||||
|
string[] importedAssets,
|
||||||
|
string[] deletedAssets,
|
||||||
|
string[] movedAssets,
|
||||||
|
string[] movedFromAssetPaths)
|
||||||
|
{
|
||||||
|
foreach (string importedAsset in importedAssets)
|
||||||
|
{
|
||||||
|
ConfigureImportedAnimationClips(importedAsset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureImportedAnimationClips(string assetPath)
|
||||||
|
{
|
||||||
|
if (!HumanoidAnimationFbxImportSettings.ShouldProcessAsset(assetPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ReimportingAssets.Remove(assetPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(AssetImporter.GetAtPath(assetPath) is ModelImporter modelImporter))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ModelImporterClipAnimation[] existingClips = modelImporter.clipAnimations;
|
||||||
|
if (existingClips != null && existingClips.Length > 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ModelImporterClipAnimation[] clips = CreateClipAnimationsFromImportedAssets(assetPath);
|
||||||
|
if (clips.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modelImporter.clipAnimations = HumanoidAnimationFbxImportSettings.ApplyClipSettings(clips);
|
||||||
|
ReimportingAssets.Add(assetPath);
|
||||||
|
modelImporter.SaveAndReimport();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ModelImporterClipAnimation[] GetAnimationClips(ModelImporter modelImporter)
|
||||||
|
{
|
||||||
|
ModelImporterClipAnimation[] clips = modelImporter.clipAnimations;
|
||||||
|
if (clips != null && clips.Length > 0)
|
||||||
|
{
|
||||||
|
return clips;
|
||||||
|
}
|
||||||
|
|
||||||
|
clips = modelImporter.defaultClipAnimations;
|
||||||
|
if (clips != null && clips.Length > 0)
|
||||||
|
{
|
||||||
|
return clips;
|
||||||
|
}
|
||||||
|
|
||||||
|
TakeInfo[] takes = modelImporter.importedTakeInfos;
|
||||||
|
if (takes == null || takes.Length == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<ModelImporterClipAnimation>();
|
||||||
|
}
|
||||||
|
|
||||||
|
clips = new ModelImporterClipAnimation[takes.Length];
|
||||||
|
for (int i = 0; i < takes.Length; i++)
|
||||||
|
{
|
||||||
|
clips[i] = HumanoidAnimationFbxImportSettings.CreateClipAnimation(takes[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return clips;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Avatar LoadCityboyAvatar()
|
||||||
|
{
|
||||||
|
Avatar fallbackAvatar = null;
|
||||||
|
UnityEngine.Object[] assets = AssetDatabase.LoadAllAssetsAtPath(HumanoidAnimationFbxImportSettings.CityboyAvatarPath);
|
||||||
|
|
||||||
|
foreach (UnityEngine.Object asset in assets)
|
||||||
|
{
|
||||||
|
if (!(asset is Avatar avatar))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackAvatar == null)
|
||||||
|
{
|
||||||
|
fallbackAvatar = avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(avatar.name, HumanoidAnimationFbxImportSettings.CityboyAvatarName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return avatar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackAvatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ModelImporterClipAnimation[] CreateClipAnimationsFromImportedAssets(string assetPath)
|
||||||
|
{
|
||||||
|
UnityEngine.Object[] assets = AssetDatabase.LoadAllAssetRepresentationsAtPath(assetPath);
|
||||||
|
var clips = new List<ModelImporterClipAnimation>();
|
||||||
|
|
||||||
|
foreach (UnityEngine.Object asset in assets)
|
||||||
|
{
|
||||||
|
if (asset is AnimationClip animationClip)
|
||||||
|
{
|
||||||
|
clips.Add(HumanoidAnimationFbxImportSettings.CreateClipAnimation(animationClip));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return clips.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: eca73c54c7164f2cb8873812efa969fa
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -95,20 +95,31 @@ namespace XGame.EditorTools
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void BuildAllFromCommandLine()
|
public static void BuildVirtualInputFromCommandLine()
|
||||||
{
|
{
|
||||||
string jsonRoot = GetCommandLineValue("-uiJsonRoot");
|
string jsonRoot = ResolveCommandLineJsonRoot();
|
||||||
if (string.IsNullOrEmpty(jsonRoot))
|
string[] files =
|
||||||
{
|
{
|
||||||
jsonRoot = ResolveJsonRoot();
|
"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.");
|
||||||
}
|
}
|
||||||
else if (!Path.IsPathRooted(jsonRoot))
|
built++;
|
||||||
{
|
}
|
||||||
string projectDir = Directory.GetParent(Application.dataPath).FullName;
|
Debug.Log("[UIGen] virtual input prefabs built: " + built);
|
||||||
string repoDir = Directory.GetParent(projectDir)?.FullName ?? projectDir;
|
|
||||||
jsonRoot = Path.Combine(repoDir, jsonRoot);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void BuildAllFromCommandLine()
|
||||||
|
{
|
||||||
|
string jsonRoot = ResolveCommandLineJsonRoot();
|
||||||
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,145 @@
|
|||||||
|
using NUnit.Framework;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
public sealed class HumanoidAnimationFbxImportSettingsTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void ShouldProcessActorAnimationFbx()
|
||||||
|
{
|
||||||
|
Assert.That(
|
||||||
|
HumanoidAnimationFbxImportSettings.ShouldProcessAsset(
|
||||||
|
"Assets/Game/Art/Actor/Controller/Class/Caster/Standing Walk Forward.fbx"),
|
||||||
|
Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ShouldNotProcessCityboySourceAvatar()
|
||||||
|
{
|
||||||
|
Assert.That(
|
||||||
|
HumanoidAnimationFbxImportSettings.ShouldProcessAsset(
|
||||||
|
HumanoidAnimationFbxImportSettings.CityboyAvatarPath),
|
||||||
|
Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ShouldProcessActorFbxEvenWhenCustomClipAnimationsAreEmpty()
|
||||||
|
{
|
||||||
|
Assert.That(
|
||||||
|
HumanoidAnimationFbxImportSettings.ShouldProcessAsset(
|
||||||
|
"Assets/Game/Art/Actor/Controller/Class/Caster/Standing 2H Magic Area Attack 02.fbx"),
|
||||||
|
Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ShouldNotProcessFbxOutsideActorFolder()
|
||||||
|
{
|
||||||
|
Assert.That(
|
||||||
|
HumanoidAnimationFbxImportSettings.ShouldProcessAsset(
|
||||||
|
"Assets/Res/cityboy_uv2/cityboy_uv2.fbx"),
|
||||||
|
Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ShouldNotUseCityboyAvatarWhenFbxDoesNotContainCityboyHipBone()
|
||||||
|
{
|
||||||
|
Assert.That(
|
||||||
|
HumanoidAnimationFbxImportSettings.ShouldUseCityboyAvatar(
|
||||||
|
new byte[] { 72, 105, 112, 115 }),
|
||||||
|
Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ShouldUseCityboyAvatarWhenFbxContainsCityboyHipBone()
|
||||||
|
{
|
||||||
|
Assert.That(
|
||||||
|
HumanoidAnimationFbxImportSettings.ShouldUseCityboyAvatar(
|
||||||
|
System.Text.Encoding.ASCII.GetBytes("Model::mixamorig:Hips")),
|
||||||
|
Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ApplyClipSettingsUsesOriginalRotationAndBakesRotationAndY()
|
||||||
|
{
|
||||||
|
var clip = new ModelImporterClipAnimation
|
||||||
|
{
|
||||||
|
lockRootRotation = false,
|
||||||
|
keepOriginalOrientation = false,
|
||||||
|
lockRootHeightY = false
|
||||||
|
};
|
||||||
|
|
||||||
|
ModelImporterClipAnimation updated = HumanoidAnimationFbxImportSettings.ApplyClipSettings(clip);
|
||||||
|
|
||||||
|
Assert.That(updated.lockRootRotation, Is.True);
|
||||||
|
Assert.That(updated.keepOriginalOrientation, Is.True);
|
||||||
|
Assert.That(updated.lockRootHeightY, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ApplyClipSettingsUpdatesEveryClipBeforeWriteback()
|
||||||
|
{
|
||||||
|
var clips = new[]
|
||||||
|
{
|
||||||
|
new ModelImporterClipAnimation { name = "Attack01" },
|
||||||
|
new ModelImporterClipAnimation { name = "Attack02" }
|
||||||
|
};
|
||||||
|
|
||||||
|
ModelImporterClipAnimation[] updated = HumanoidAnimationFbxImportSettings.ApplyClipSettings(clips);
|
||||||
|
|
||||||
|
Assert.That(updated, Has.Length.EqualTo(2));
|
||||||
|
Assert.That(updated[0].lockRootRotation, Is.True);
|
||||||
|
Assert.That(updated[0].keepOriginalOrientation, Is.True);
|
||||||
|
Assert.That(updated[0].lockRootHeightY, Is.True);
|
||||||
|
Assert.That(updated[1].lockRootRotation, Is.True);
|
||||||
|
Assert.That(updated[1].keepOriginalOrientation, Is.True);
|
||||||
|
Assert.That(updated[1].lockRootHeightY, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void CreateClipAnimationFromTakeInfoUsesTakeFramesAndAppliesRootSettings()
|
||||||
|
{
|
||||||
|
var takeInfo = new TakeInfo
|
||||||
|
{
|
||||||
|
name = "mixamo.com",
|
||||||
|
defaultClipName = "Standing 2H Magic Area Attack 02",
|
||||||
|
startTime = 0f,
|
||||||
|
stopTime = 4.5f,
|
||||||
|
sampleRate = 30f
|
||||||
|
};
|
||||||
|
|
||||||
|
ModelImporterClipAnimation clip = HumanoidAnimationFbxImportSettings.CreateClipAnimation(takeInfo);
|
||||||
|
|
||||||
|
Assert.That(clip.name, Is.EqualTo("Standing 2H Magic Area Attack 02"));
|
||||||
|
Assert.That(clip.takeName, Is.EqualTo("mixamo.com"));
|
||||||
|
Assert.That(clip.firstFrame, Is.EqualTo(0f));
|
||||||
|
Assert.That(clip.lastFrame, Is.EqualTo(135f));
|
||||||
|
Assert.That(clip.lockRootRotation, Is.True);
|
||||||
|
Assert.That(clip.keepOriginalOrientation, Is.True);
|
||||||
|
Assert.That(clip.lockRootHeightY, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void CreateClipAnimationFromImportedClipUsesClipNameAndLength()
|
||||||
|
{
|
||||||
|
var importedClip = new AnimationClip
|
||||||
|
{
|
||||||
|
name = "Standing 2H Magic Area Attack 02",
|
||||||
|
frameRate = 30f
|
||||||
|
};
|
||||||
|
importedClip.SetCurve(
|
||||||
|
string.Empty,
|
||||||
|
typeof(Transform),
|
||||||
|
"m_LocalPosition.x",
|
||||||
|
new AnimationCurve(new Keyframe(0f, 0f), new Keyframe(4.5f, 0f)));
|
||||||
|
|
||||||
|
ModelImporterClipAnimation clip = HumanoidAnimationFbxImportSettings.CreateClipAnimation(importedClip);
|
||||||
|
|
||||||
|
Assert.That(clip.name, Is.EqualTo("Standing 2H Magic Area Attack 02"));
|
||||||
|
Assert.That(clip.takeName, Is.EqualTo("Standing 2H Magic Area Attack 02"));
|
||||||
|
Assert.That(clip.firstFrame, Is.EqualTo(0f));
|
||||||
|
Assert.That(clip.lastFrame, Is.EqualTo(135f));
|
||||||
|
Assert.That(clip.lockRootRotation, Is.True);
|
||||||
|
Assert.That(clip.keepOriginalOrientation, Is.True);
|
||||||
|
Assert.That(clip.lockRootHeightY, Is.True);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 60252bd67a3943b38f65d188a2128557
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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,13 +40,12 @@ 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;
|
||||||
private const float CameraMinDistance = 3.5f;
|
private const float CameraMinDistance = 2.0f;
|
||||||
private const float CameraMaxDistance = 14f;
|
private const float CameraMaxDistance = 8f;
|
||||||
private const float CameraDefaultDistance = 9.5f;
|
private const float CameraDefaultDistance = 4.5f;
|
||||||
private const float CameraMinPitch = 20f;
|
private const float CameraMinPitch = 20f;
|
||||||
private const float CameraMaxPitch = 75f;
|
private const float CameraMaxPitch = 75f;
|
||||||
private const float CameraMouseOrbitSensitivity = 0.2f;
|
private const float CameraMouseOrbitSensitivity = 0.2f;
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ namespace XGame
|
|||||||
sun.type = LightType.Directional;
|
sun.type = LightType.Directional;
|
||||||
sun.color = RuntimeSunColor;
|
sun.color = RuntimeSunColor;
|
||||||
sun.intensity = Mathf.Max(sun.intensity, RuntimeSunIntensity);
|
sun.intensity = Mathf.Max(sun.intensity, RuntimeSunIntensity);
|
||||||
sun.shadows = LightShadows.None;
|
//sun.shadows = LightShadows.None;
|
||||||
sun.transform.rotation = Quaternion.Euler(50f, -30f, 0f);
|
sun.transform.rotation = Quaternion.Euler(50f, -30f, 0f);
|
||||||
RenderSettings.sun = sun;
|
RenderSettings.sun = sun;
|
||||||
return sun;
|
return sun;
|
||||||
|
|||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
# Virtual HUD Input Design
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
设计一套可在大厅和小游戏中复用的 HUD 输入组件:
|
||||||
|
|
||||||
|
- 通用摇杆组件:复用当前大厅摇杆视觉和交互体验,输出纯方向输入事件。
|
||||||
|
- 通用按键组件:按 MOBA 手游右下角操作区设计,提供 1 个主按键 + 4 个次按键,支持完整技能键手势和状态反馈。
|
||||||
|
|
||||||
|
组件层只输出输入事件和输入状态,不直接发送网络消息,不绑定大厅移动、小游戏技能、角色职业或服务端协议。大厅和小游戏各自订阅事件,并把事件映射到自己的业务逻辑。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
本设计覆盖:
|
||||||
|
|
||||||
|
- 通用 `VirtualJoystick` 运行时组件。
|
||||||
|
- 通用 `VirtualActionButton` 单按钮组件。
|
||||||
|
- 通用 `VirtualActionPad` 组合组件。
|
||||||
|
- `UI_VirtualJoystick` 和 `UI_VirtualActionPad` 预设描述,沿用 `Doc/UIPrefabCreater/*.json` 到 prefab 的现有管线。
|
||||||
|
- 大厅摇杆从 `LobbyWorldController` 私有采样逻辑迁移到通用组件。
|
||||||
|
- 小游戏按需加载通用摇杆或右下角按键组,并消费纯输入事件。
|
||||||
|
|
||||||
|
不覆盖:
|
||||||
|
|
||||||
|
- 小游戏协议设计。
|
||||||
|
- 具体技能系统、伤害、冷却规则、服务器校验。
|
||||||
|
- 专用 MOBA 圆形技能切图生产。第一版复用现有 Common UI 资源,后续可替换美术资源而不改变输入 API。
|
||||||
|
- Unity 新 Input System 迁移。第一版继续使用当前项目已有的 UGUI/EventSystem/StandaloneInputModule 路线。
|
||||||
|
|
||||||
|
## 方案选择
|
||||||
|
|
||||||
|
采用“通用 VirtualInput 组件层”方案。
|
||||||
|
|
||||||
|
备选方案一是只重构现有大厅摇杆并单独新增技能键。这种方式改动少,但大厅和小游戏输入会快速分叉。
|
||||||
|
|
||||||
|
备选方案二是新增完整输入服务和动作映射系统。这更接近正式 Input System,但当前项目还没有动作注册、上下文切换、优先级和协议映射基础,第一版过重。
|
||||||
|
|
||||||
|
推荐方案在中间:新增独立组件层,组件只负责输入采样和视觉反馈,业务层自己映射响应。它能复用现有 UI 管线,也不会把小游戏协议提前写死到客户端框架里。
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
新增目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Client/Assets/Script/xmain/Input/
|
||||||
|
```
|
||||||
|
|
||||||
|
核心组件:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace XGame.Input
|
||||||
|
{
|
||||||
|
public sealed class VirtualJoystick : MonoBehaviour
|
||||||
|
{
|
||||||
|
public Vector2 Value { get; }
|
||||||
|
public bool IsActive { get; }
|
||||||
|
|
||||||
|
public event Action Started;
|
||||||
|
public event Action<Vector2, bool> Changed;
|
||||||
|
public event Action Ended;
|
||||||
|
|
||||||
|
public bool IsScreenPositionInAcquireArea(Vector2 screenPosition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`VirtualJoystick` 负责鼠标/触摸捕获、死区、归一化方向、摇杆头视觉偏移。它不负责移动、相机、网络同步。
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace XGame.Input
|
||||||
|
{
|
||||||
|
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 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace XGame.Input
|
||||||
|
{
|
||||||
|
public sealed class VirtualActionButton : MonoBehaviour
|
||||||
|
{
|
||||||
|
public ActionButtonId ButtonId;
|
||||||
|
public bool IsInteractable { get; }
|
||||||
|
|
||||||
|
public event Action<ActionButtonEvent> ButtonEvent;
|
||||||
|
|
||||||
|
public void SetCooldown(float remainingSeconds, float durationSeconds);
|
||||||
|
public void SetCharges(int count);
|
||||||
|
public void SetButtonEnabled(bool enabled);
|
||||||
|
public void SetCancelArea(RectTransform cancelArea);
|
||||||
|
public void SetIcon(Sprite icon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`VirtualActionButton` 是单个技能键状态机。它负责点击、长按、拖拽瞄准、取消、冷却遮罩、次数、禁用视觉。它不关心按钮对应普攻、技能还是道具。
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace XGame.Input
|
||||||
|
{
|
||||||
|
public sealed class VirtualActionPad : MonoBehaviour
|
||||||
|
{
|
||||||
|
public event Action<ActionButtonEvent> ButtonEvent;
|
||||||
|
|
||||||
|
public VirtualActionButton GetButton(ActionButtonId id);
|
||||||
|
public void SetCooldown(ActionButtonId id, float remainingSeconds, float durationSeconds);
|
||||||
|
public void SetCharges(ActionButtonId id, int count);
|
||||||
|
public void SetButtonEnabled(ActionButtonId id, bool enabled);
|
||||||
|
public void SetCancelAreaVisible(bool visible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`VirtualActionPad` 管理右下角 5 个按钮,按稳定 ID 转发事件,并提供统一状态刷新 API。
|
||||||
|
|
||||||
|
## 预设
|
||||||
|
|
||||||
|
### UI_VirtualJoystick
|
||||||
|
|
||||||
|
新增:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Doc/UIPrefabCreater/UI_VirtualJoystick.json
|
||||||
|
Client/Assets/Game/Art/UI/Prefab/UI_VirtualJoystick.prefab
|
||||||
|
```
|
||||||
|
|
||||||
|
它从现有 `UI_LobbyJoystick` 演进而来,保留:
|
||||||
|
|
||||||
|
- `Base`
|
||||||
|
- `Ring`
|
||||||
|
- `Knob`
|
||||||
|
|
||||||
|
默认布局:
|
||||||
|
|
||||||
|
- 根节点锚到左下角。
|
||||||
|
- 参考分辨率 `2048x1024`。
|
||||||
|
- 默认中心位置约 `(260, 220)`。
|
||||||
|
- 默认尺寸 `148x148`。
|
||||||
|
- 根节点挂 `VirtualJoystick`。
|
||||||
|
- 继续复用 `lobby_joystick_base`、`lobby_joystick_ring`、`lobby_joystick_knob`。
|
||||||
|
|
||||||
|
旧 `UI_LobbyJoystick` 可以先保留,避免已有资源引用断裂。大厅运行时改用 `UI_VirtualJoystick` 后,旧预设可作为兼容资源,后续再清理。
|
||||||
|
|
||||||
|
### UI_VirtualActionPad
|
||||||
|
|
||||||
|
新增:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Doc/UIPrefabCreater/UI_VirtualActionPad.json
|
||||||
|
Client/Assets/Game/Art/UI/Prefab/UI_VirtualActionPad.prefab
|
||||||
|
```
|
||||||
|
|
||||||
|
默认布局:
|
||||||
|
|
||||||
|
- 根节点锚到右下角。
|
||||||
|
- `Btn_Primary`:主按键,最大,位于右下核心位置。
|
||||||
|
- `Btn_Skill1`、`Btn_Skill2`、`Btn_Skill3`:围绕主按键左侧和上方弧形排布。
|
||||||
|
- `Btn_Skill4`:更靠上或靠左的小技能键,用于特殊动作、召唤师技能或道具。
|
||||||
|
- `CancelArea`:默认在按钮组上方偏中区域,仅拖拽技能时显示。
|
||||||
|
|
||||||
|
每个按钮标准子节点:
|
||||||
|
|
||||||
|
- `Icon`
|
||||||
|
- `CooldownMask`
|
||||||
|
- `CooldownText`
|
||||||
|
- `ChargeText`
|
||||||
|
- `AimIndicator` 或 `DragArrow`
|
||||||
|
|
||||||
|
第一版视觉复用现有 Common 资源:
|
||||||
|
|
||||||
|
- 主按键底图优先使用 `btn_primary_*`。
|
||||||
|
- 次按键底图优先使用 `btn_small_*` 或 `btn_warning_*`。
|
||||||
|
- 图标先使用 `ico_sword`、`ico_shield`、`ico_star`、`ico_heart`、`ico_item` 等临时示意图标。
|
||||||
|
|
||||||
|
如果后续补充圆形技能键切图,只需替换 JSON 资源和 prefab,不改变 `VirtualActionButton`/`VirtualActionPad` API。
|
||||||
|
|
||||||
|
## 摇杆交互
|
||||||
|
|
||||||
|
`VirtualJoystick` 行为:
|
||||||
|
|
||||||
|
1. 捕获鼠标左键或触摸。
|
||||||
|
2. 按 pointer ID 锁定一次操作,直到松开或取消。
|
||||||
|
3. 支持开始捕获区域放大倍率,默认视觉半径 `1.6x` 内都可开始控制。
|
||||||
|
4. 计算屏幕指针相对摇杆中心的向量,并归一化到 `[-1, 1]`。
|
||||||
|
5. 小于死区时输出 `Value=Vector2.zero` 且 `IsActive=false`。
|
||||||
|
6. 大于死区时输出归一化方向且 `IsActive=true`。
|
||||||
|
7. `Knob` 最大视觉移动距离由容器半径和 knob 半径计算,避免不同尺寸下越界。
|
||||||
|
8. 松开时重置方向、复位 knob、触发 `Ended`。
|
||||||
|
|
||||||
|
事件:
|
||||||
|
|
||||||
|
- `Started`:从未激活进入捕获。
|
||||||
|
- `Changed(value, active)`:方向或激活状态变化时触发。
|
||||||
|
- `Ended`:释放当前 pointer 后触发。
|
||||||
|
|
||||||
|
`IsScreenPositionInAcquireArea` 提供给相机、点击移动等外部输入排除逻辑复用,避免各业务重复计算摇杆区域。
|
||||||
|
|
||||||
|
## 按键交互
|
||||||
|
|
||||||
|
`VirtualActionButton` 行为:
|
||||||
|
|
||||||
|
- `PointerDown`:记录按下时间、pointer ID,发 `Down`。
|
||||||
|
- `PointerDrag`:计算从按钮中心到当前指针的 `LocalVector`、`Direction`、`Distance01`,发 `Drag`。
|
||||||
|
- 进入取消区时切换取消悬停视觉,并在事件中设置 `IsCanceled=true`。
|
||||||
|
- 按住超过阈值后发一次 `HoldStart`。
|
||||||
|
- 按住期间按固定间隔发 `HoldTick`。
|
||||||
|
- 松开时:
|
||||||
|
- 如果在取消区,发 `Cancel`。
|
||||||
|
- 如果拖拽距离超过释放阈值,发 `Release`,并携带方向。
|
||||||
|
- 如果未拖拽且未达到长按阈值,发 `Tap`。
|
||||||
|
- 如果已经长按,先补 `HoldEnd`,再按最终状态发 `Release` 或 `Cancel`。
|
||||||
|
- 冷却中、禁用、次数为 0 时不进入按下流程,只发 `Blocked`。
|
||||||
|
|
||||||
|
默认阈值:
|
||||||
|
|
||||||
|
- 长按阈值:`0.35s`。
|
||||||
|
- HoldTick 间隔:`0.1s`。
|
||||||
|
- 拖拽释放阈值:按钮半径的 `0.25`。
|
||||||
|
- 最大拖拽归一化距离:按钮半径的 `1.5`。
|
||||||
|
|
||||||
|
这些阈值作为序列化字段暴露,允许 prefab 或业务按需调节。
|
||||||
|
|
||||||
|
## 状态与视觉
|
||||||
|
|
||||||
|
按钮状态:
|
||||||
|
|
||||||
|
- Normal:可点击。
|
||||||
|
- Pressed:按住。
|
||||||
|
- Dragging:拖拽瞄准。
|
||||||
|
- CancelHover:拖入取消区。
|
||||||
|
- Cooldown:冷却中。
|
||||||
|
- Disabled:禁用。
|
||||||
|
- EmptyCharges:次数为 0。
|
||||||
|
|
||||||
|
视觉刷新:
|
||||||
|
|
||||||
|
- `CooldownMask` 使用 Filled Image 或代码调整 `fillAmount`。
|
||||||
|
- `CooldownText` 显示向上取整的剩余秒数。
|
||||||
|
- `ChargeText` 在次数大于 0 时显示次数,次数为 0 时按钮进入不可用视觉。
|
||||||
|
- `AimIndicator`/`DragArrow` 在拖拽时显示方向,松手后隐藏。
|
||||||
|
- `CancelArea` 默认隐藏,进入拖拽瞄准时显示;取消或释放后隐藏。
|
||||||
|
|
||||||
|
缺少可选节点时跳过对应视觉,不影响事件输出。缺少关键节点时记录日志并禁用该组件。
|
||||||
|
|
||||||
|
## 大厅接入
|
||||||
|
|
||||||
|
`LobbyWorldController` 保留大厅业务职责:
|
||||||
|
|
||||||
|
- 本地角色移动。
|
||||||
|
- 远端角色同步。
|
||||||
|
- 相机旋转和缩放。
|
||||||
|
- 大厅网络移动同步。
|
||||||
|
|
||||||
|
改动方向:
|
||||||
|
|
||||||
|
- 移除 `LobbyWorldController` 内部摇杆采样字段和方法。
|
||||||
|
- 加载 `UI_VirtualJoystick.prefab` 后获取 `VirtualJoystick`。
|
||||||
|
- 订阅 `Changed/Ended`,把 `Vector2` 缓存为大厅移动输入。
|
||||||
|
- `GetMoveDirection` 继续把键盘 WASD 和摇杆方向合成世界方向。
|
||||||
|
- 相机输入排除摇杆区域时调用 `VirtualJoystick.IsScreenPositionInAcquireArea`。
|
||||||
|
- 大厅进入小游戏隔离时,将通用摇杆 root 或 `VirtualJoystick` 列入 `MiniGameStageIsolation` 管理,避免大厅输入穿透。
|
||||||
|
|
||||||
|
## 小游戏接入
|
||||||
|
|
||||||
|
小游戏不强制显示这套 HUD。需要时在 `IGameClient.OnEnter` 中加载:
|
||||||
|
|
||||||
|
- `UI_VirtualJoystick`
|
||||||
|
- `UI_VirtualActionPad`
|
||||||
|
- 或两者都加载
|
||||||
|
|
||||||
|
小游戏客户端订阅事件并自行映射:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Primary Tap -> 普攻 opcode
|
||||||
|
Skill1 Release -> 带方向释放技能 opcode
|
||||||
|
Skill2 Cancel -> 不发送技能,播放本地取消反馈
|
||||||
|
Joystick Changed -> 本地移动或发送移动输入
|
||||||
|
```
|
||||||
|
|
||||||
|
`GameClientCtx` 第一版不扩展输入服务,继续保持资源加载和事件订阅模式。这样不会改变小游戏框架合同,也不会影响已有 RPS 小游戏。
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
- 摇杆 prefab 缺少 `Knob`:记录错误日志,禁用 `VirtualJoystick`。
|
||||||
|
- 按钮缺少 `Icon`:仍可输出事件,但不显示图标。
|
||||||
|
- 按钮缺少 `CooldownMask`、`CooldownText` 或 `ChargeText`:跳过对应视觉刷新。
|
||||||
|
- 缺少 `CancelArea`:拖拽取消功能关闭,按钮仍支持点击和拖拽释放。
|
||||||
|
- 多点触控按 pointer ID 锁定,同一个按钮未释放前忽略其他 pointer。
|
||||||
|
- 不同按钮可以各自捕获不同 pointer。
|
||||||
|
- 冷却、禁用、次数为 0 时只发 `Blocked`,不发 `Tap`、`Release` 或 `Cancel`。
|
||||||
|
- 组件销毁或禁用时,如果正在按住,需要复位内部状态和视觉,避免残留按下态。
|
||||||
|
|
||||||
|
## 测试策略
|
||||||
|
|
||||||
|
EditMode 测试:
|
||||||
|
|
||||||
|
1. `VirtualJoystick` 死区:小于死区输出 inactive,松开后归零。
|
||||||
|
2. `VirtualJoystick` 归一化:超过半径时 clamp 到单位圆。
|
||||||
|
3. `VirtualJoystick` knob 行程:按容器和 knob 尺寸计算,不越界。
|
||||||
|
4. `VirtualActionButton` 点击:Down -> Tap。
|
||||||
|
5. `VirtualActionButton` 拖拽释放:Down -> Drag -> Release,并携带方向。
|
||||||
|
6. `VirtualActionButton` 拖拽取消:Down -> Drag -> Cancel。
|
||||||
|
7. `VirtualActionButton` 长按:Down -> HoldStart -> HoldTick -> HoldEnd。
|
||||||
|
8. `VirtualActionButton` Blocked:冷却中、禁用、次数为 0 时不发释放类事件。
|
||||||
|
9. `VirtualActionPad` 能按 `ActionButtonId` 找到 5 个按钮并转发事件。
|
||||||
|
10. 标准节点绑定成功,缺少可选节点不抛异常。
|
||||||
|
11. 状态 API 能刷新冷却遮罩、冷却文字、次数文字和可用状态。
|
||||||
|
|
||||||
|
大厅接入测试:
|
||||||
|
|
||||||
|
- `LobbyWorldController` 消费 `VirtualJoystick` 输出值合成移动方向。
|
||||||
|
- 相机输入排除摇杆区域时使用通用组件判断。
|
||||||
|
- 小游戏隔离时大厅摇杆不可见或不可交互。
|
||||||
|
|
||||||
|
手动 Unity 烟测:
|
||||||
|
|
||||||
|
- PC 鼠标拖动摇杆。
|
||||||
|
- 触摸或模拟触摸拖动摇杆。
|
||||||
|
- 主按键点击释放。
|
||||||
|
- 技能键拖拽瞄准释放。
|
||||||
|
- 技能键拖拽到取消区取消。
|
||||||
|
- 冷却遮罩和倒计时显示。
|
||||||
|
- 次数耗尽后按钮进入不可用态。
|
||||||
|
- 退出小游戏后大厅输入恢复。
|
||||||
|
|
||||||
|
## 实施顺序建议
|
||||||
|
|
||||||
|
1. 为 `VirtualJoystick` 和 `VirtualActionButton` 写核心状态测试。
|
||||||
|
2. 实现 `VirtualJoystick`。
|
||||||
|
3. 实现 `VirtualActionButton` 和 `VirtualActionPad`。
|
||||||
|
4. 新增 `UI_VirtualJoystick.json` 和 `UI_VirtualActionPad.json`。
|
||||||
|
5. 生成并检查 prefab。
|
||||||
|
6. 迁移 `LobbyWorldController` 使用 `VirtualJoystick`。
|
||||||
|
7. 加测试和手动烟测。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 大厅能继续使用左下角摇杆移动角色。
|
||||||
|
- 摇杆输入来自 `VirtualJoystick` 事件,而不是 `LobbyWorldController` 私有采样逻辑。
|
||||||
|
- 小游戏能加载右下角 `VirtualActionPad` 并收到 5 个按钮的纯输入事件。
|
||||||
|
- 技能键支持点击、长按、拖拽释放、拖拽取消、冷却、次数、禁用。
|
||||||
|
- 冷却中或次数为 0 时不会误发释放事件。
|
||||||
|
- 缺少可选视觉节点不会导致运行时异常。
|
||||||
|
- 现有小游戏框架合同不需要变更。
|
||||||
Reference in New Issue
Block a user