Add animations, fix radio volume, fix keybinds
This commit is contained in:
2
meta.xml
2
meta.xml
@@ -10,6 +10,7 @@
|
||||
<!-- Server -->
|
||||
<script src="scripts/server/class.js" type="server" language="javascript" />
|
||||
<script src="scripts/server/account.js" type="server" language="javascript" />
|
||||
<script src="scripts/server/animation.js" type="server" language="javascript" />
|
||||
<!--<script src="scripts/server/ammunation.js" type="server" language="javascript" />-->
|
||||
<script src="scripts/server/anticheat.js" type="server" language="javascript" />
|
||||
<script src="scripts/server/ban.js" type="server" language="javascript" />
|
||||
@@ -86,6 +87,7 @@
|
||||
<file type="client" src="files/fonts/roboto-regular.ttf" />
|
||||
<file type="client" src="files/fonts/pricedown.ttf" />
|
||||
<file type="client" src="files/images/skins/none.png" />
|
||||
<file type="client" src="files/images/signs/rentals.png" />
|
||||
|
||||
<!-- Client Scripts -->
|
||||
<script src="scripts/client/gui.js" type="client" language="javascript" />
|
||||
|
||||
@@ -38,7 +38,7 @@ function addAllNetworkHandlers() {
|
||||
addNetworkHandler("ag.position", setLocalPlayerPosition);
|
||||
addNetworkHandler("ag.heading", setLocalPlayerHeading);
|
||||
addNetworkHandler("ag.interior", setLocalPlayerInterior);
|
||||
|
||||
addNetworkHandler("ag.minuteDuration", setMinuteDuration);
|
||||
addNetworkHandler("ag.showJobRouteStop", showJobRouteStop);
|
||||
addNetworkHandler("ag.snow", setSnowState);
|
||||
addNetworkHandler("ag.health", setLocalPlayerHealth);
|
||||
@@ -81,6 +81,8 @@ function addAllNetworkHandlers() {
|
||||
addNetworkHandler("ag.obj.sync", syncObjectProperties);
|
||||
|
||||
addNetworkHandler("ag.veh.repair", repairVehicle);
|
||||
|
||||
addNetworkHandler("ag.pedAnim", makePedPlayAnimation);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
@@ -216,7 +218,7 @@ function sendServerNewAFKStatus(state) {
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
function playStreamingRadio(url, loop) {
|
||||
function playStreamingRadio(url, loop, volume) {
|
||||
//gta.forceRadioChannel(-1);
|
||||
if(url == "") {
|
||||
if(streamingRadio != null) {
|
||||
@@ -230,7 +232,7 @@ function playStreamingRadio(url, loop) {
|
||||
}
|
||||
|
||||
streamingRadio = audio.createSoundFromURL(url, loop);
|
||||
streamingRadio.volume = 0.5;
|
||||
streamingRadio.volume = volume/100;
|
||||
streamingRadio.play();
|
||||
}
|
||||
|
||||
@@ -238,7 +240,7 @@ function playStreamingRadio(url, loop) {
|
||||
|
||||
function setStreamingRadioVolume(volume) {
|
||||
if(streamingRadio != null) {
|
||||
streamingRadio.volume = volume;
|
||||
streamingRadio.volume = volume/100;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,4 +256,14 @@ function setEnterPropertyKey(key) {
|
||||
enterPropertyKey = key;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
function makePedPlayAnimation(pedId, animGroup, animId, animType, animSpeed) {
|
||||
if(animType == VRR_ANIMTYPE_ADD) {
|
||||
getElementFromId(pedId).addAnimation(animGroup, animId);
|
||||
} else if(animType == VRR_ANIMTYPE_BLEND) {
|
||||
getElementFromId(pedId).blendAnimation(animGroup, animId, animSpeed);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
42
scripts/server/animation.js
Normal file
42
scripts/server/animation.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// ===========================================================================
|
||||
// Vortrex's Roleplay Resource
|
||||
// https://github.com/VortrexFTW/gtac_roleplay
|
||||
// ===========================================================================
|
||||
// FILE: animation.js
|
||||
// DESC: Provides animation functions and usage
|
||||
// TYPE: Server (JavaScript)
|
||||
// ===========================================================================
|
||||
|
||||
function initAnimationScript() {
|
||||
logToConsole(LOG_DEBUG, "[VRR.Animation]: Initializing animation script ...");
|
||||
logToConsole(LOG_DEBUG, "[VRR.Animation]: Animation script initialized!");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
function playPlayerAnimationCommand(command, params, client) {
|
||||
if(areParamsEmpty(params)) {
|
||||
messagePlayerSyntax(client, getCommandSyntaxText(command));
|
||||
return false;
|
||||
}
|
||||
|
||||
let animationSlot = getAnimationFromParams(params);
|
||||
|
||||
if(!animationSlot) {
|
||||
messagePlayerError(client, "That animation doesn't exist!");
|
||||
return false;
|
||||
}
|
||||
|
||||
getPlayerData(client).currentAnimation = animationSlot;
|
||||
getPlayerData(client).animationStart = getCurrentUnixTimestamp();
|
||||
//setEntityData(getPlayerData(client).ped, "ag.animation", animationSlot, true);
|
||||
makePedPlayAnimation(getPlayerData(client).ped, animationSlot);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
function getAnimationData(animationSlot, gameId = getServerGame()) {
|
||||
return getGameData().animations[gameId][animationSlot];
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
@@ -433,14 +433,14 @@ function sendPlayerMouseCursorToggle(client) {
|
||||
// ===========================================================================
|
||||
|
||||
function sendAddAccountKeyBindToClient(client, key, keyState) {
|
||||
logToConsole(LOG_DEBUG, `[VRR.Client] Sending added keybind to ${getPlayerDisplayForConsole(client)} (Key: ${sdl.getKeyName(key)}, State: ${(keyState) ? "down" : "up"})`);
|
||||
logToConsole(LOG_DEBUG, `[VRR.Client] Sending added keybind to ${getPlayerDisplayForConsole(client)} (Key: ${toUpperCase(getKeyNameFromId(key))}, State: ${(keyState) ? "down" : "up"})`);
|
||||
triggerNetworkEvent("ag.addKeyBind", client, toInteger(key), (keyState) ? KEYSTATE_DOWN : KEYSTATE_UP);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
function sendRemoveAccountKeyBindToClient(client, key) {
|
||||
logToConsole(LOG_DEBUG, `[VRR.Client] Sending deleted keybind to ${getPlayerDisplayForConsole(client)} (Key: ${sdl.getKeyName(key)})`);
|
||||
logToConsole(LOG_DEBUG, `[VRR.Client] Sending deleted keybind to ${getPlayerDisplayForConsole(client)} (Key: ${toUpperCase(getKeyNameFromId(key))})`);
|
||||
triggerNetworkEvent("ag.delKeyBind", client, toInteger(key));
|
||||
}
|
||||
|
||||
@@ -659,7 +659,7 @@ function setPlayerCameraLookAt(client, cameraPosition, lookAtPosition) {
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
function setTimeMinuteDuration(client, minuteDuration) {
|
||||
function sendTimeMinuteDurationToPlayer(client, minuteDuration) {
|
||||
triggerNetworkEvent("ag.minuteDuration", client, minuteDuration);
|
||||
}
|
||||
|
||||
@@ -878,8 +878,8 @@ function sendPlayerChatScrollLines(client, amount) {
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
function playRadioStreamForPlayer(client, streamURL) {
|
||||
triggerNetworkEvent("ag.radioStream", client, streamURL, getPlayerData(client).streamingRadioVolume);
|
||||
function playRadioStreamForPlayer(client, streamURL, loop = false, volume = 0) {
|
||||
triggerNetworkEvent("ag.radioStream", client, streamURL, loop, volume);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
@@ -901,4 +901,12 @@ function sendPlayerEnterPropertyKey(client, key) {
|
||||
triggerNetworkEvent("ag.enterPropertyKey", client, key);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
function makePedPlayAnimation(ped, animationSlot) {
|
||||
let animationData = getAnimationData(animationSlot);
|
||||
|
||||
triggerNetworkEvent("ag.pedAnim", null, ped.id, animationData[1], animationData[2], animationData[3], animationData[4]);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
@@ -5,125 +5,6 @@
|
||||
// FILE: keybind.js
|
||||
// DESC: Provides keybind handlers and functions
|
||||
// TYPE: Server (JavaScript)
|
||||
// ===========================================================================
|
||||
|
||||
let bindableKeys = {
|
||||
SDLK_BACKSPACE: "backspace",
|
||||
SDLK_TAB: "tab",
|
||||
SDLK_RETURN: "return",
|
||||
SDLK_ESCAPE: "escape",
|
||||
SDLK_SPACE: "space",
|
||||
SDLK_EXCLAIM: "exclamation",
|
||||
SDLK_QUOTEDBL: "doublequote",
|
||||
SDLK_HASH: "hashtag",
|
||||
SDLK_DOLLAR: "dollar",
|
||||
SDLK_PERCENT: "percent",
|
||||
SDLK_AMPERSAND: "ampersand",
|
||||
SDLK_QUOTE: "quote",
|
||||
SDLK_LEFTPAREN: "leftparenthesis",
|
||||
SDLK_RIGHTPAREN: "rightparenthesis",
|
||||
SDLK_ASTERISK: "asterisk",
|
||||
SDLK_PLUS: "plus",
|
||||
SDLK_COMMA: "comma",
|
||||
SDLK_MINUS: "minus",
|
||||
SDLK_PERIOD: "period",
|
||||
SDLK_SLASH: "slash",
|
||||
SDLK_0: "0",
|
||||
SDLK_1: "1",
|
||||
SDLK_2: "2",
|
||||
SDLK_3: "3",
|
||||
SDLK_4: "4",
|
||||
SDLK_5: "5",
|
||||
SDLK_6: "6",
|
||||
SDLK_7: "7",
|
||||
SDLK_8: "8",
|
||||
SDLK_9: "9",
|
||||
SDLK_COLON: "colon",
|
||||
SDLK_SEMICOLON: "semicolon",
|
||||
SDLK_LESS: "less",
|
||||
SDLK_EQUALS: "equals",
|
||||
SDLK_GREATER: "greater",
|
||||
SDLK_QUESTION: "questionmark",
|
||||
SDLK_AT: "at",
|
||||
SDLK_LEFTBRACKET: "leftbracket",
|
||||
SDLK_BACKSLASH: "backslash",
|
||||
SDLK_RIGHTBRACKET: "rightbracket",
|
||||
SDLK_UNDERSCORE: "underscore",
|
||||
SDLK_a: "a",
|
||||
SDLK_b: "b",
|
||||
SDLK_c: "c",
|
||||
SDLK_d: "d",
|
||||
SDLK_e: "e",
|
||||
SDLK_f: "f",
|
||||
SDLK_g: "g",
|
||||
SDLK_h: "h",
|
||||
SDLK_i: "i",
|
||||
SDLK_j: "j",
|
||||
SDLK_k: "k",
|
||||
SDLK_l: "l",
|
||||
SDLK_m: "m",
|
||||
SDLK_n: "n",
|
||||
SDLK_o: "o",
|
||||
SDLK_p: "p",
|
||||
SDLK_q: "q",
|
||||
SDLK_r: "r",
|
||||
SDLK_s: "s",
|
||||
SDLK_t: "t",
|
||||
SDLK_u: "u",
|
||||
SDLK_v: "v",
|
||||
SDLK_w: "w",
|
||||
SDLK_x: "x",
|
||||
SDLK_y: "y",
|
||||
SDLK_z: "z",
|
||||
SDLK_DELETE: "delete",
|
||||
SDLK_CAPSLOCK: "capslock",
|
||||
SDLK_F1: "f12",
|
||||
SDLK_F2: "f2",
|
||||
SDLK_F3: "f3",
|
||||
SDLK_F4: "f4",
|
||||
SDLK_F5: "f5",
|
||||
SDLK_F6: "f6",
|
||||
SDLK_F7: "f7",
|
||||
SDLK_F8: "f8",
|
||||
SDLK_F9: "f9",
|
||||
SDLK_F10: "f10",
|
||||
SDLK_F11: "f11",
|
||||
SDLK_F12: "f12",
|
||||
SDLK_PRINTSCREEN: "printscreen",
|
||||
SDLK_SCROLLLOCK: "scrolllock",
|
||||
SDLK_PAUSE: "pause",
|
||||
SDLK_INSERT: "insert",
|
||||
SDLK_HOME: "home",
|
||||
SDLK_PAGEUP: "pageup",
|
||||
SDLK_END: "end",
|
||||
SDLK_PAGEDOWN: "pagedown",
|
||||
SDLK_RIGHT: "right",
|
||||
SDLK_LEFT: "left",
|
||||
SDLK_DOWN: "down",
|
||||
SDLK_UP: "up",
|
||||
SDLK_KP_DIVIDE: "numdivide",
|
||||
SDLK_KP_MULTIPLY: "nummultiply",
|
||||
SDLK_KP_MINUS: "numminus",
|
||||
SDLK_KP_PLUS: "numplus",
|
||||
SDLK_KP_ENTER: "numenter",
|
||||
SDLK_KP_1: "num1",
|
||||
SDLK_KP_2: "num2",
|
||||
SDLK_KP_3: "num3",
|
||||
SDLK_KP_4: "num4",
|
||||
SDLK_KP_5: "num5",
|
||||
SDLK_KP_6: "num6",
|
||||
SDLK_KP_7: "num7",
|
||||
SDLK_KP_8: "num8",
|
||||
SDLK_KP_9: "num9",
|
||||
SDLK_KP_0: "num0",
|
||||
SDLK_KP_PERIOD: "numperiod",
|
||||
SDLK_LCTRL: "leftctrl",
|
||||
SDLK_LSHIFT: "leftshift",
|
||||
SDLK_LALT: "leftalt",
|
||||
SDLK_RCTRL: "rightctrl",
|
||||
SDLK_RSHIFT: "rightshift",
|
||||
SDLK_RALT: "rightalt",
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
@@ -159,8 +40,8 @@ function addKeyBindCommand(command, params, client) {
|
||||
return false;
|
||||
}
|
||||
|
||||
addPlayerKeyBind(keyId, tempCommand, tempParams);
|
||||
messagePlayerSuccess(client, `You binded the ${getInlineChatColourByName("lightGrey")}${sdl.getKeyName(keyId)} ${getInlineChatColourByName("white")}key to command: ${getInlineChatColourByName("lightGrey")}/${tempCommand} ${tempParams}`);
|
||||
addPlayerKeyBind(client, keyId, tempCommand, tempParams);
|
||||
messagePlayerSuccess(client, `You binded the ${getInlineChatColourByName("lightGrey")}${toUpperCase(getKeyNameFromId(keyId))} ${getInlineChatColourByName("white")}key to command: ${getInlineChatColourByName("lightGrey")}/${tempCommand} ${tempParams}`);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
@@ -183,24 +64,24 @@ function removeKeyBindCommand(command, params, client) {
|
||||
}
|
||||
|
||||
removePlayerKeyBind(client, keyId);
|
||||
messagePlayerSuccess(client, `You removed the keybind for the ${getInlineChatColourByName("lightGrey")}${sdl.getKeyName(keyId)} ${getInlineChatColourByName("white")}key`);
|
||||
messagePlayerSuccess(client, `You removed the keybind for the ${getInlineChatColourByName("lightGrey")}${toUpperCase(getKeyNameFromId(keyId))} ${getInlineChatColourByName("white")}key`);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
function addPlayerKeyBind(client, keyId, tempCommand, tempParams) {
|
||||
let keyBindData = new serverClasses.keyBindData(keyId, `${tempCommand} ${tempParams}`);
|
||||
let keyBindData = new serverClasses.keyBindData(false, keyId, `${tempCommand} ${tempParams}`);
|
||||
getPlayerData(client).accountData.keyBinds.push(keyBindData);
|
||||
sendAddAccountKeyBindToClient(client, getPlayerKeyBindForKey(client, keyId));
|
||||
sendAddAccountKeyBindToClient(client, keyId, KEYSTATE_UP);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
function removePlayerKeyBind(client, keyId) {
|
||||
quickDatabaseQuery(`DELETE FROM acct_hotkey WHERE acct_hotkey_acct = ${getPlayerData(client).accountData.databaseId} AND acct_hotkey_key = ${keyId}`);
|
||||
for(let i in accountKeyBinds) {
|
||||
if(accountKeyBinds[i].key == keyId) {
|
||||
accountKeyBinds.splice(i, 1);
|
||||
for(let i in getPlayerData(client).accountData.keyBinds) {
|
||||
if(getPlayerData(client).accountData.keyBinds[i].key == keyId) {
|
||||
getPlayerData(client).accountData.keyBinds.splice(i, 1);
|
||||
}
|
||||
}
|
||||
sendRemoveAccountKeyBindToClient(client, keyId);
|
||||
@@ -209,9 +90,8 @@ function removePlayerKeyBind(client, keyId) {
|
||||
// ===========================================================================
|
||||
|
||||
function doesPlayerHaveKeyBindForCommand(client, command) {
|
||||
let accountKeyBinds = getPlayerData(client).accountData.keyBinds;
|
||||
for(let i in accountKeyBinds) {
|
||||
if(toLowerCase(accountKeyBinds[i].commandString.split(" ")[0]) == toLowerCase(command)) {
|
||||
for(let i in getPlayerData(client).accountData.keyBinds) {
|
||||
if(toLowerCase(getPlayerData(client).accountData.keyBinds[i].commandString.split(" ")[0]) == toLowerCase(command)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -221,10 +101,9 @@ function doesPlayerHaveKeyBindForCommand(client, command) {
|
||||
// ===========================================================================
|
||||
|
||||
function getPlayerKeyBindForCommand(client, command) {
|
||||
let accountKeyBinds = getPlayerData(client).accountData.keyBinds;
|
||||
for(let i in accountKeyBinds) {
|
||||
if(toLowerCase(accountKeyBinds[i].commandString.split(" ")[0]) == toLowerCase(command)) {
|
||||
return accountKeyBinds[i];
|
||||
for(let i in getPlayerData(client).accountData.keyBinds) {
|
||||
if(toLowerCase(getPlayerData(client).accountData.keyBinds[i].commandString.split(" ")[0]) == toLowerCase(command)) {
|
||||
return getPlayerData(client).accountData.keyBinds[i];
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -233,9 +112,8 @@ function getPlayerKeyBindForCommand(client, command) {
|
||||
// ===========================================================================
|
||||
|
||||
function doesPlayerHaveKeyBindForKey(client, key) {
|
||||
let accountKeyBinds = getPlayerData(client).accountData.keyBinds;
|
||||
for(let i in accountKeyBinds) {
|
||||
if(accountKeyBinds[i].key == key) {
|
||||
for(let i in getPlayerData(client).accountData.keyBinds) {
|
||||
if(getPlayerData(client).accountData.keyBinds[i].key == key) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -245,10 +123,9 @@ function doesPlayerHaveKeyBindForKey(client, key) {
|
||||
// ===========================================================================
|
||||
|
||||
function getPlayerKeyBindForKey(client, key) {
|
||||
let accountKeyBinds = getPlayerData(client).accountData.keyBinds;
|
||||
for(let i in accountKeyBinds) {
|
||||
if(accountKeyBinds[i].key == key) {
|
||||
return accountKeyBinds[i];
|
||||
for(let i in getPlayerData(client).accountData.keyBinds) {
|
||||
if(getPlayerData(client).accountData.keyBinds[i].key == key) {
|
||||
return getPlayerData(client).accountData.keyBinds[i];
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -265,7 +142,7 @@ function playerUsedKeyBind(client, key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
logToConsole(LOG_DEBUG, `[VRR.KeyBind] ${getPlayerDisplayForConsole(client)} used keybind ${sdl.getKeyName(key)} (${key})`);
|
||||
logToConsole(LOG_DEBUG, `[VRR.KeyBind] ${getPlayerDisplayForConsole(client)} used keybind ${toUpperCase(getKeyNameFromId(key))} (${key})`);
|
||||
if(doesPlayerHaveKeyBindForKey(client, key)) {
|
||||
let keyBindData = getPlayerKeyBindForKey(client, key);
|
||||
if(keyBindData.enabled) {
|
||||
@@ -291,23 +168,6 @@ function sendAccountKeyBindsToClient(client) {
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
function getKeyIdFromParams(params) {
|
||||
let tempParams = toLowerCase(toString(params));
|
||||
|
||||
let sdlName = sdl.getKeyFromName(tempParams);
|
||||
if(sdlName != null) {
|
||||
return sdlName;
|
||||
}
|
||||
|
||||
for(let i in bindableKeys) {
|
||||
if(bindableKeys[i].indexOf(tempParams)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
function loadKeyBindConfiguration() {
|
||||
let keyBindConfigFile = loadTextFile("config/keybind.json");
|
||||
return JSON.parse(keyBindConfigFile);
|
||||
|
||||
@@ -87,8 +87,7 @@ function playStreamingRadioCommand(command, params, client) {
|
||||
let clients = getClients();
|
||||
for(let i in clients) {
|
||||
if(getPlayerVehicle(client) == getPlayerVehicle(clients[i])) {
|
||||
playRadioStreamForPlayer(clients[i], radioStations[radioStationId-1].url);
|
||||
setPlayerStreamingRadioVolume(client, getPlayerData(client).streamingRadioVolume);
|
||||
playRadioStreamForPlayer(clients[i], radioStations[radioStationId-1].url, true, getPlayerData(client).streamingRadioVolume);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,9 +107,20 @@ function setStreamingRadioVolumeCommand(command, params, client) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setPlayerStreamingRadioVolume(client, toInteger(volumeLevel)/100);
|
||||
getPlayerData(client).streamingRadioVolume = toInteger(volumeLevel)/100;
|
||||
messagePlayerSuccess(client, `You set your streaming radio volume to ${volumeLevel}`);
|
||||
setPlayerStreamingRadioVolume(client, toInteger(volumeLevel));
|
||||
getPlayerData(client).streamingRadioVolume = toInteger(volumeLevel);
|
||||
let volumeEmoji = '';
|
||||
if(volumeLevel >= 60) {
|
||||
volumeEmoji = '🔊 ';
|
||||
} else if(volumeLevel >= 30 && volumeLevel < 60) {
|
||||
volumeEmoji = '🔉 ';
|
||||
} else if(volumeLevel > 0 && volumeLevel < 30) {
|
||||
volumeEmoji = '🔈 ';
|
||||
} else if(volumeLevel <= 0) {
|
||||
volumeEmoji = '🔇 ';
|
||||
}
|
||||
|
||||
messagePlayerSuccess(client, `${volumeEmoji}You set your streaming radio volume to ${volumeLevel}%`);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
Reference in New Issue
Block a user