mirror of
https://github.com/EsotericSoftware/spine-runtimes.git
synced 2026-03-26 22:49:01 +08:00
[unity] Updated editors.
This commit is contained in:
parent
f07c2967d5
commit
acd50548d5
@ -482,24 +482,8 @@ namespace Spine.Unity.Editor {
|
|||||||
for (int a = 0; a < slotAttachments.Count; a++) {
|
for (int a = 0; a < slotAttachments.Count; a++) {
|
||||||
Attachment attachment = slotAttachments[a];
|
Attachment attachment = slotAttachments[a];
|
||||||
string attachmentName = slotAttachmentNames[a];
|
string attachmentName = slotAttachmentNames[a];
|
||||||
|
Texture2D icon = Icons.GetAttachmentIcon(attachment);
|
||||||
Texture2D icon = null;
|
|
||||||
var type = attachment.GetType();
|
|
||||||
|
|
||||||
if (type == typeof(RegionAttachment))
|
|
||||||
icon = Icons.image;
|
|
||||||
else if (type == typeof(MeshAttachment))
|
|
||||||
icon = Icons.mesh;
|
|
||||||
else if (type == typeof(BoundingBoxAttachment))
|
|
||||||
icon = Icons.boundingBox;
|
|
||||||
else if (type == typeof(PathAttachment))
|
|
||||||
icon = Icons.boundingBox;
|
|
||||||
else
|
|
||||||
icon = Icons.warning;
|
|
||||||
//JOHN: left todo: Icon for paths. Generic icon for unidentified attachments.
|
|
||||||
|
|
||||||
bool initialState = slot.Attachment == attachment;
|
bool initialState = slot.Attachment == attachment;
|
||||||
|
|
||||||
bool toggled = EditorGUILayout.ToggleLeft(new GUIContent(attachmentName, icon), slot.Attachment == attachment);
|
bool toggled = EditorGUILayout.ToggleLeft(new GUIContent(attachmentName, icon), slot.Attachment == attachment);
|
||||||
|
|
||||||
if (!defaultSkinAttachmentNames.Contains(attachmentName)) {
|
if (!defaultSkinAttachmentNames.Contains(attachmentName)) {
|
||||||
@ -526,7 +510,7 @@ namespace Spine.Unity.Editor {
|
|||||||
if (skeletonJSON.objectReferenceValue == null) {
|
if (skeletonJSON.objectReferenceValue == null) {
|
||||||
warnings.Add("Missing Skeleton JSON");
|
warnings.Add("Missing Skeleton JSON");
|
||||||
} else {
|
} else {
|
||||||
if (SpineEditorUtilities.IsValidSpineData((TextAsset)skeletonJSON.objectReferenceValue) == false) {
|
if (SpineEditorUtilities.IsSpineData((TextAsset)skeletonJSON.objectReferenceValue) == false) {
|
||||||
warnings.Add("Skeleton data file is not a valid JSON or binary file.");
|
warnings.Add("Skeleton data file is not a valid JSON or binary file.");
|
||||||
} else {
|
} else {
|
||||||
#if !SPINE_TK2D
|
#if !SPINE_TK2D
|
||||||
|
|||||||
BIN
spine-unity/Assets/spine-unity/Editor/GUI/icon-path.png
Normal file
BIN
spine-unity/Assets/spine-unity/Editor/GUI/icon-path.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 519 B |
59
spine-unity/Assets/spine-unity/Editor/GUI/icon-path.png.meta
Normal file
59
spine-unity/Assets/spine-unity/Editor/GUI/icon-path.png.meta
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: dbc817a6c9e9c5747b7f6261bf5d1d09
|
||||||
|
timeCreated: 1482240904
|
||||||
|
licenseType: Free
|
||||||
|
TextureImporter:
|
||||||
|
fileIDToRecycleName: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
mipmaps:
|
||||||
|
mipMapMode: 0
|
||||||
|
enableMipMap: 0
|
||||||
|
linearTexture: 1
|
||||||
|
correctGamma: 0
|
||||||
|
fadeOut: 0
|
||||||
|
borderMipMap: 0
|
||||||
|
mipMapFadeDistanceStart: 1
|
||||||
|
mipMapFadeDistanceEnd: 3
|
||||||
|
bumpmap:
|
||||||
|
convertToNormalMap: 0
|
||||||
|
externalNormalMap: 0
|
||||||
|
heightScale: 0.25
|
||||||
|
normalMapFilter: 0
|
||||||
|
isReadable: 0
|
||||||
|
grayScaleToAlpha: 0
|
||||||
|
generateCubemap: 0
|
||||||
|
cubemapConvolution: 0
|
||||||
|
cubemapConvolutionSteps: 7
|
||||||
|
cubemapConvolutionExponent: 1.5
|
||||||
|
seamlessCubemap: 0
|
||||||
|
textureFormat: -3
|
||||||
|
maxTextureSize: 1024
|
||||||
|
textureSettings:
|
||||||
|
filterMode: -1
|
||||||
|
aniso: 1
|
||||||
|
mipBias: -1
|
||||||
|
wrapMode: 1
|
||||||
|
nPOTScale: 0
|
||||||
|
lightmap: 0
|
||||||
|
rGBM: 0
|
||||||
|
compressionQuality: 50
|
||||||
|
allowsAlphaSplitting: 0
|
||||||
|
spriteMode: 0
|
||||||
|
spriteExtrude: 1
|
||||||
|
spriteMeshType: 1
|
||||||
|
alignment: 0
|
||||||
|
spritePivot: {x: 0.5, y: 0.5}
|
||||||
|
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||||
|
spritePixelsToUnits: 100
|
||||||
|
alphaIsTransparency: 1
|
||||||
|
spriteTessellationDetail: -1
|
||||||
|
textureType: 2
|
||||||
|
buildTargetSettings: []
|
||||||
|
spriteSheet:
|
||||||
|
serializedVersion: 2
|
||||||
|
sprites: []
|
||||||
|
outline: []
|
||||||
|
spritePackingTag:
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@ -62,6 +62,7 @@ namespace Spine.Unity.Editor {
|
|||||||
public static Texture2D boundingBox;
|
public static Texture2D boundingBox;
|
||||||
public static Texture2D mesh;
|
public static Texture2D mesh;
|
||||||
public static Texture2D weights;
|
public static Texture2D weights;
|
||||||
|
public static Texture2D path;
|
||||||
public static Texture2D skin;
|
public static Texture2D skin;
|
||||||
public static Texture2D skinsRoot;
|
public static Texture2D skinsRoot;
|
||||||
public static Texture2D animation;
|
public static Texture2D animation;
|
||||||
@ -100,10 +101,25 @@ namespace Spine.Unity.Editor {
|
|||||||
skeletonUtility = (Texture2D)AssetDatabase.LoadMainAssetAtPath(SpineEditorUtilities.editorGUIPath + "/icon-skeletonUtility.png");
|
skeletonUtility = (Texture2D)AssetDatabase.LoadMainAssetAtPath(SpineEditorUtilities.editorGUIPath + "/icon-skeletonUtility.png");
|
||||||
hingeChain = (Texture2D)AssetDatabase.LoadMainAssetAtPath(SpineEditorUtilities.editorGUIPath + "/icon-hingeChain.png");
|
hingeChain = (Texture2D)AssetDatabase.LoadMainAssetAtPath(SpineEditorUtilities.editorGUIPath + "/icon-hingeChain.png");
|
||||||
subMeshRenderer = (Texture2D)AssetDatabase.LoadMainAssetAtPath(SpineEditorUtilities.editorGUIPath + "/icon-subMeshRenderer.png");
|
subMeshRenderer = (Texture2D)AssetDatabase.LoadMainAssetAtPath(SpineEditorUtilities.editorGUIPath + "/icon-subMeshRenderer.png");
|
||||||
|
path = (Texture2D)AssetDatabase.LoadMainAssetAtPath(SpineEditorUtilities.editorGUIPath + "/icon-path.png");
|
||||||
|
|
||||||
unityIcon = EditorGUIUtility.FindTexture("SceneAsset Icon");
|
unityIcon = EditorGUIUtility.FindTexture("SceneAsset Icon");
|
||||||
controllerIcon = EditorGUIUtility.FindTexture("AnimatorController Icon");
|
controllerIcon = EditorGUIUtility.FindTexture("AnimatorController Icon");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Texture2D GetAttachmentIcon (Attachment attachment) {
|
||||||
|
if (attachment is RegionAttachment)
|
||||||
|
return Icons.image;
|
||||||
|
// Analysis disable once CanBeReplacedWithTryCastAndCheckForNull
|
||||||
|
else if (attachment is MeshAttachment)
|
||||||
|
return ((MeshAttachment)attachment).IsWeighted() ? Icons.weights : Icons.mesh;
|
||||||
|
else if (attachment is BoundingBoxAttachment)
|
||||||
|
return Icons.boundingBox;
|
||||||
|
else if (attachment is PathAttachment)
|
||||||
|
return Icons.path;
|
||||||
|
else
|
||||||
|
return Icons.warning;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string editorPath = "";
|
public static string editorPath = "";
|
||||||
@ -551,12 +567,12 @@ namespace Spine.Unity.Editor {
|
|||||||
imagePaths.Add(str);
|
imagePaths.Add(str);
|
||||||
break;
|
break;
|
||||||
case ".json":
|
case ".json":
|
||||||
if (IsValidSpineData((TextAsset)AssetDatabase.LoadAssetAtPath(str, typeof(TextAsset))))
|
if (IsSpineData((TextAsset)AssetDatabase.LoadAssetAtPath(str, typeof(TextAsset))))
|
||||||
skeletonPaths.Add(str);
|
skeletonPaths.Add(str);
|
||||||
break;
|
break;
|
||||||
case ".bytes":
|
case ".bytes":
|
||||||
if (str.ToLower().EndsWith(".skel.bytes", System.StringComparison.Ordinal)) {
|
if (str.ToLower().EndsWith(".skel.bytes", System.StringComparison.Ordinal)) {
|
||||||
if (IsValidSpineData((TextAsset)AssetDatabase.LoadAssetAtPath(str, typeof(TextAsset))))
|
if (IsSpineData((TextAsset)AssetDatabase.LoadAssetAtPath(str, typeof(TextAsset))))
|
||||||
skeletonPaths.Add(str);
|
skeletonPaths.Add(str);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@ -602,7 +618,7 @@ namespace Spine.Unity.Editor {
|
|||||||
|
|
||||||
switch (result) {
|
switch (result) {
|
||||||
case -1:
|
case -1:
|
||||||
Debug.Log("Select Atlas");
|
//Debug.Log("Select Atlas");
|
||||||
AtlasAsset selectedAtlas = GetAtlasDialog(Path.GetDirectoryName(sp));
|
AtlasAsset selectedAtlas = GetAtlasDialog(Path.GetDirectoryName(sp));
|
||||||
if (selectedAtlas != null) {
|
if (selectedAtlas != null) {
|
||||||
localAtlases.Clear();
|
localAtlases.Clear();
|
||||||
@ -1167,37 +1183,52 @@ namespace Spine.Unity.Editor {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool IsValidSpineData (TextAsset asset) {
|
public static bool IsSpineData (TextAsset asset) {
|
||||||
if (asset.name.Contains(".skel")) return true;
|
bool isSpineData = false;
|
||||||
|
string rawVersion = null;
|
||||||
|
|
||||||
object obj = null;
|
if (asset.name.Contains(".skel")) {
|
||||||
obj = Json.Deserialize(new StringReader(asset.text));
|
try {
|
||||||
|
rawVersion = SkeletonBinary.GetVersionString(new MemoryStream(asset.bytes));
|
||||||
|
//Debug.Log(rawVersion);
|
||||||
|
} catch (System.Exception e) {
|
||||||
|
Debug.LogErrorFormat("Failed to read '{0}'. It is likely not a binary Spine SkeletonData file.\n{1}", asset.name, e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var obj = Json.Deserialize(new StringReader(asset.text));
|
||||||
|
if (obj == null) {
|
||||||
|
Debug.LogErrorFormat("'{0}' is not valid JSON.", asset.name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (obj == null) {
|
var root = obj as Dictionary<string, object>;
|
||||||
Debug.LogError("Is not valid JSON.");
|
if (root == null) {
|
||||||
return false;
|
Debug.LogError("Parser returned an incorrect type.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSpineData = root.ContainsKey("skeleton");
|
||||||
|
if (isSpineData) {
|
||||||
|
var skeletonInfo = (Dictionary<string, object>)root["skeleton"];
|
||||||
|
object jv;
|
||||||
|
skeletonInfo.TryGetValue("spine", out jv);
|
||||||
|
rawVersion = jv as string;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var root = obj as Dictionary<string, object>;
|
|
||||||
if (root == null) {
|
|
||||||
Debug.LogError("Parser returned an incorrect type.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isSpineJson = root.ContainsKey("skeleton");
|
|
||||||
|
|
||||||
// Version warning
|
// Version warning
|
||||||
if (isSpineJson) {
|
{
|
||||||
var skeletonInfo = (Dictionary<string, object>)root["skeleton"];
|
string runtimeVersion = compatibleVersions[0][0] + "." + compatibleVersions[0][1];
|
||||||
object jv;
|
|
||||||
skeletonInfo.TryGetValue("spine", out jv);
|
if (string.IsNullOrEmpty(rawVersion)) {
|
||||||
string jsonVersion = jv as string;
|
Debug.LogWarningFormat("Skeleton '{0}' has no version information. It may be incompatible with your runtime version: spine-unity v{1}", asset.name, runtimeVersion);
|
||||||
if (!string.IsNullOrEmpty(jsonVersion)) {
|
} else {
|
||||||
string[] jsonVersionSplit = jsonVersion.Split('.');
|
string[] versionSplit = rawVersion.Split('.');
|
||||||
bool match = false;
|
bool match = false;
|
||||||
foreach (var version in compatibleVersions) {
|
foreach (var version in compatibleVersions) {
|
||||||
bool primaryMatch = version[0] == int.Parse(jsonVersionSplit[0]);
|
bool primaryMatch = version[0] == int.Parse(versionSplit[0]);
|
||||||
bool secondaryMatch = version[1] == int.Parse(jsonVersionSplit[1]);
|
bool secondaryMatch = version[1] == int.Parse(versionSplit[1]);
|
||||||
|
|
||||||
// if (isFixVersionRequired) secondaryMatch &= version[2] <= int.Parse(jsonVersionSplit[2]);
|
// if (isFixVersionRequired) secondaryMatch &= version[2] <= int.Parse(jsonVersionSplit[2]);
|
||||||
|
|
||||||
@ -1207,16 +1238,12 @@ namespace Spine.Unity.Editor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!match) {
|
if (!match)
|
||||||
string runtimeVersion = compatibleVersions[0][0] + "." + compatibleVersions[0][1];
|
Debug.LogWarningFormat("Skeleton '{0}' (exported with Spine {1}) may be incompatible with your runtime version: spine-unity v{2}", asset.name, rawVersion, runtimeVersion);
|
||||||
Debug.LogWarning(string.Format("Skeleton '{0}' (exported with Spine {1}) may be incompatible with your runtime version: spine-unity v{2}", asset.name, jsonVersion, runtimeVersion));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
isSpineJson = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return isSpineJson;
|
return isSpineData;
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@ -1434,6 +1461,25 @@ namespace Spine.Unity.Editor {
|
|||||||
public static string GetPathSafeRegionName (AtlasRegion region) {
|
public static string GetPathSafeRegionName (AtlasRegion region) {
|
||||||
return region.name.Replace("/", "_");
|
return region.name.Replace("/", "_");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static int ReadVarint (Stream input, bool optimizePositive) {
|
||||||
|
int b = input.ReadByte();
|
||||||
|
int result = b & 0x7F;
|
||||||
|
if ((b & 0x80) != 0) {
|
||||||
|
b = input.ReadByte();
|
||||||
|
result |= (b & 0x7F) << 7;
|
||||||
|
if ((b & 0x80) != 0) {
|
||||||
|
b = input.ReadByte();
|
||||||
|
result |= (b & 0x7F) << 14;
|
||||||
|
if ((b & 0x80) != 0) {
|
||||||
|
b = input.ReadByte();
|
||||||
|
result |= (b & 0x7F) << 21;
|
||||||
|
if ((b & 0x80) != 0) result |= (input.ReadByte() & 0x7F) << 28;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return optimizePositive ? result : ((result >> 1) ^ -(result & 1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class SpineHandles {
|
public static class SpineHandles {
|
||||||
|
|||||||
@ -36,6 +36,8 @@ using System.Collections.Generic;
|
|||||||
using Spine;
|
using Spine;
|
||||||
|
|
||||||
namespace Spine.Unity.Editor {
|
namespace Spine.Unity.Editor {
|
||||||
|
using Icons = SpineEditorUtilities.Icons;
|
||||||
|
|
||||||
[CustomEditor(typeof(SkeletonUtilityBone)), CanEditMultipleObjects]
|
[CustomEditor(typeof(SkeletonUtilityBone)), CanEditMultipleObjects]
|
||||||
public class SkeletonUtilityBoneInspector : UnityEditor.Editor {
|
public class SkeletonUtilityBoneInspector : UnityEditor.Editor {
|
||||||
SerializedProperty mode, boneName, zPosition, position, rotation, scale, overrideAlpha, parentReference;
|
SerializedProperty mode, boneName, zPosition, position, rotation, scale, overrideAlpha, parentReference;
|
||||||
@ -162,11 +164,11 @@ namespace Spine.Unity.Editor {
|
|||||||
using (new GUILayout.HorizontalScope()) {
|
using (new GUILayout.HorizontalScope()) {
|
||||||
EditorGUILayout.Space();
|
EditorGUILayout.Space();
|
||||||
using (new EditorGUI.DisabledGroupScope(multiObject || !utilityBone.valid || utilityBone.bone == null || utilityBone.bone.Children.Count == 0)) {
|
using (new EditorGUI.DisabledGroupScope(multiObject || !utilityBone.valid || utilityBone.bone == null || utilityBone.bone.Children.Count == 0)) {
|
||||||
if (GUILayout.Button(new GUIContent("Add Child", SpineEditorUtilities.Icons.bone), GUILayout.MinWidth(120), GUILayout.Height(24)))
|
if (GUILayout.Button(new GUIContent("Add Child", Icons.bone), GUILayout.MinWidth(120), GUILayout.Height(24)))
|
||||||
BoneSelectorContextMenu("", utilityBone.bone.Children, "<Recursively>", SpawnChildBoneSelected);
|
BoneSelectorContextMenu("", utilityBone.bone.Children, "<Recursively>", SpawnChildBoneSelected);
|
||||||
}
|
}
|
||||||
using (new EditorGUI.DisabledGroupScope(multiObject || !utilityBone.valid || utilityBone.bone == null || containsOverrides)) {
|
using (new EditorGUI.DisabledGroupScope(multiObject || !utilityBone.valid || utilityBone.bone == null || containsOverrides)) {
|
||||||
if (GUILayout.Button(new GUIContent("Add Override", SpineEditorUtilities.Icons.poseBones), GUILayout.MinWidth(120), GUILayout.Height(24)))
|
if (GUILayout.Button(new GUIContent("Add Override", Icons.poseBones), GUILayout.MinWidth(120), GUILayout.Height(24)))
|
||||||
SpawnOverride();
|
SpawnOverride();
|
||||||
}
|
}
|
||||||
EditorGUILayout.Space();
|
EditorGUILayout.Space();
|
||||||
@ -175,14 +177,14 @@ namespace Spine.Unity.Editor {
|
|||||||
using (new GUILayout.HorizontalScope()) {
|
using (new GUILayout.HorizontalScope()) {
|
||||||
EditorGUILayout.Space();
|
EditorGUILayout.Space();
|
||||||
using (new EditorGUI.DisabledGroupScope(multiObject || !utilityBone.valid || !canCreateHingeChain)) {
|
using (new EditorGUI.DisabledGroupScope(multiObject || !utilityBone.valid || !canCreateHingeChain)) {
|
||||||
if (GUILayout.Button(new GUIContent("Create Hinge Chain", SpineEditorUtilities.Icons.hingeChain), GUILayout.Width(150), GUILayout.Height(24)))
|
if (GUILayout.Button(new GUIContent("Create Hinge Chain", Icons.hingeChain), GUILayout.Width(150), GUILayout.Height(24)))
|
||||||
CreateHingeChain();
|
CreateHingeChain();
|
||||||
}
|
}
|
||||||
EditorGUILayout.Space();
|
EditorGUILayout.Space();
|
||||||
}
|
}
|
||||||
|
|
||||||
using (new EditorGUI.DisabledGroupScope(multiObject || boundingBoxTable.Count == 0)) {
|
using (new EditorGUI.DisabledGroupScope(multiObject || boundingBoxTable.Count == 0)) {
|
||||||
EditorGUILayout.LabelField(new GUIContent("Bounding Boxes", SpineEditorUtilities.Icons.boundingBox), EditorStyles.boldLabel);
|
EditorGUILayout.LabelField(new GUIContent("Bounding Boxes", Icons.boundingBox), EditorStyles.boldLabel);
|
||||||
|
|
||||||
foreach (var entry in boundingBoxTable){
|
foreach (var entry in boundingBoxTable){
|
||||||
Slot slot = entry.Key;
|
Slot slot = entry.Key;
|
||||||
|
|||||||
@ -130,7 +130,7 @@ namespace Spine.Unity.Editor {
|
|||||||
foreach (var attachment in pair.Value) {
|
foreach (var attachment in pair.Value) {
|
||||||
GUI.contentColor = slot.Attachment == attachment ? Color.white : Color.grey;
|
GUI.contentColor = slot.Attachment == attachment ? Color.white : Color.grey;
|
||||||
EditorGUI.indentLevel = baseIndent + 2;
|
EditorGUI.indentLevel = baseIndent + 2;
|
||||||
var icon = (attachment is MeshAttachment) ? Icons.mesh : Icons.image;
|
var icon = Icons.GetAttachmentIcon(attachment);
|
||||||
bool isAttached = (attachment == slot.Attachment);
|
bool isAttached = (attachment == slot.Attachment);
|
||||||
bool swap = EditorGUILayout.ToggleLeft(new GUIContent(attachment.Name, icon), attachment == slot.Attachment);
|
bool swap = EditorGUILayout.ToggleLeft(new GUIContent(attachment.Name, icon), attachment == slot.Attachment);
|
||||||
if (isAttached != swap) {
|
if (isAttached != swap) {
|
||||||
|
|||||||
@ -194,7 +194,6 @@ namespace Spine.Unity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void RegisterConstraint (SkeletonUtilityConstraint constraint) {
|
public void RegisterConstraint (SkeletonUtilityConstraint constraint) {
|
||||||
|
|
||||||
if (utilityConstraints.Contains(constraint))
|
if (utilityConstraints.Contains(constraint))
|
||||||
return;
|
return;
|
||||||
else {
|
else {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user