Implemented cameras
Added basic cameras with panning
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package io.github.eldek0;
|
||||
|
||||
import com.badlogic.gdx.Game;
|
||||
import com.badlogic.gdx.Gdx;
|
||||
import com.badlogic.gdx.graphics.OrthographicCamera;
|
||||
import com.badlogic.gdx.math.Vector2;
|
||||
import com.badlogic.gdx.math.Vector3;
|
||||
@@ -41,6 +42,7 @@ public class App extends Game {
|
||||
@Override
|
||||
public void render() {
|
||||
super.render();
|
||||
Gdx.graphics.setTitle(Gdx.graphics.getFramesPerSecond() + " FPS");
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -59,12 +59,12 @@ public class GameAssetManager {
|
||||
// =========================================================
|
||||
// PATHS — Cameras (utils)
|
||||
// =========================================================
|
||||
private static final String CAM_PATH = "sprites/cameras/";
|
||||
private static final String CAM_UTILS_PATH = CAM_PATH + "utils/";
|
||||
private static final String CAM_LABELS_PATH = CAM_PATH + "Labels/";
|
||||
private static final String CAM_STATIC_PATH = CAM_PATH + "static/";
|
||||
private static final String CAM_LOC_PATH = CAM_PATH + "locations/";
|
||||
private static final String CAM_MANGLE_PATH = CAM_PATH + "mangle/";
|
||||
public static final String CAM_PATH = "sprites/cameras/";
|
||||
public static final String CAM_UTILS_PATH = CAM_PATH + "utils/";
|
||||
public static final String CAM_LABELS_PATH = CAM_PATH + "Labels/";
|
||||
public static final String CAM_STATIC_PATH = CAM_PATH + "static/";
|
||||
public static final String CAM_LOC_PATH = CAM_PATH + "locations/";
|
||||
public static final String CAM_MANGLE_PATH = CAM_PATH + "mangle/";
|
||||
|
||||
public static final String CAMERA_MAP = CAM_UTILS_PATH + "Map.png";
|
||||
public static final String CAMERA_BORDERLINE = CAM_UTILS_PATH + "Border.png";
|
||||
|
||||
@@ -1,11 +1,239 @@
|
||||
package io.github.eldek0.game;
|
||||
|
||||
import com.badlogic.gdx.Gdx;
|
||||
import com.badlogic.gdx.Input;
|
||||
import com.badlogic.gdx.graphics.Texture;
|
||||
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
|
||||
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
|
||||
import com.badlogic.gdx.math.Rectangle;
|
||||
import com.badlogic.gdx.math.Vector2;
|
||||
import io.github.eldek0.App;
|
||||
import io.github.eldek0.screen.GameScene;
|
||||
|
||||
import static io.github.eldek0.App.assets;
|
||||
import static io.github.eldek0.asset.GameAssetManager.*;
|
||||
|
||||
public class Camera {
|
||||
public Camera(){}
|
||||
private static final int CAM_COUNT = 12;
|
||||
private static final float CAMERA_SPEED = 5f * 60f; // px/s
|
||||
|
||||
public void render(SpriteBatch batch){}
|
||||
private final float[][] BTN_POS = new float[][] {
|
||||
{ 620 - 25, 621 - 125 }, { 735 - 25, 621 - 125 },
|
||||
{ 620 - 25, 556 - 125 }, { 735 - 25, 556 - 125 },
|
||||
{ 600 - 15, 700 - 105 }, { 710 - 15, 700 - 105 },
|
||||
{ 769 - 35, 482 - 115 }, { 612 - 37, 471 - 115 },
|
||||
{ 920 - 25, 441 - 110 }, { 830 - 10, 570 - 115 },
|
||||
{ 954 - 25, 515 - 110 }, { 945 - 25, 608 - 105 }
|
||||
};;
|
||||
|
||||
public void update(float delta){}
|
||||
/** 1-based room index currently shown on the monitor. */
|
||||
private int inCameraRoom = 10;
|
||||
|
||||
private final Rectangle[] buttonScreenRects = new Rectangle[CAM_COUNT];
|
||||
|
||||
// Wide-camera panning (indices 6-11 → rooms 7-12)
|
||||
private final float[] camerasXPosition = new float[CAM_COUNT];
|
||||
private final int[] wideCameraMovDirection = new int[CAM_COUNT];
|
||||
private final long[] timerCheckpoints = new long[CAM_COUNT];
|
||||
private final long[] timerWaitTimeMs = new long[CAM_COUNT];
|
||||
|
||||
private final boolean[] occupiedCamera = new boolean[CAM_COUNT];
|
||||
|
||||
private float recBlinkTimer = 0f;
|
||||
private boolean recVisible = true;
|
||||
|
||||
private GameScene gameScene;
|
||||
|
||||
public Camera(GameScene gameScene) {
|
||||
this.gameScene = gameScene;
|
||||
java.util.Random rng = new java.util.Random();
|
||||
for (int i = 0; i < CAM_COUNT; i++) {
|
||||
timerWaitTimeMs[i] = 5000 + (long) rng.nextInt(2001); // [5000,7000] ms
|
||||
}
|
||||
this.initButtonScreenRects();
|
||||
}
|
||||
|
||||
private void initButtonScreenRects(){
|
||||
Texture btnUnsel = assets.getTexture(CAM_LABELS_PATH + "13.png");
|
||||
|
||||
for (int i = 0; i < CAM_COUNT; i++) {
|
||||
float[] btnPos = BTN_POS[i];
|
||||
float worldY = App.SCREEN_HEIGHT - btnPos[1] - btnUnsel.getHeight();
|
||||
buttonScreenRects[i] = new Rectangle(btnPos[0], worldY, btnUnsel.getWidth() , btnUnsel.getHeight());
|
||||
}
|
||||
}
|
||||
|
||||
public void update(float delta) {
|
||||
for (int i = 6; i < CAM_COUNT; i++) {
|
||||
updateCameraTimer(i, delta);
|
||||
}
|
||||
|
||||
recBlinkTimer += delta;
|
||||
if (recBlinkTimer >= 1f) {
|
||||
recBlinkTimer -= 1f;
|
||||
recVisible = !recVisible;
|
||||
}
|
||||
this.onTouchDown();
|
||||
}
|
||||
|
||||
public void renderBackground(SpriteBatch batch) {
|
||||
if (!gameScene.hud.isInsideCamera()){return;}
|
||||
renderCurrentRoom(batch);
|
||||
}
|
||||
|
||||
public void onTouchDown() {
|
||||
Vector2 position = App.convertPosToWorldPos(new Vector2(Gdx.input.getX(), Gdx.input.getY()));
|
||||
for (int i = 0; i < CAM_COUNT; i++) {
|
||||
if (buttonScreenRects[i].contains(position)) {
|
||||
if (Gdx.input.isButtonJustPressed(Input.Buttons.LEFT)) {
|
||||
inCameraRoom = i + 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void renderCurrentRoom(SpriteBatch batch) {
|
||||
int idx = inCameraRoom - 1;
|
||||
float xOff = camerasXPosition[idx];
|
||||
|
||||
if (!occupiedCamera[idx]) {
|
||||
Texture frame = getDefaultFrame(inCameraRoom);
|
||||
if (frame != null) {
|
||||
batch.draw(frame, xOff, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Texture getDefaultFrame(int room) {
|
||||
return switch (room) {
|
||||
case 1 -> assets.getTexture(CAM_LOC_PATH + "PartyRoom1/0.png");
|
||||
case 2 -> assets.getTexture(CAM_LOC_PATH + "PartyRoom2/0.png");
|
||||
case 3 -> assets.getTexture(CAM_LOC_PATH + "PartyRoom3/0.png");
|
||||
case 4 -> assets.getTexture(CAM_LOC_PATH + "PartyRoom4/0.png");
|
||||
case 5 -> assets.getTexture(CAM_LOC_PATH + "LeftAirVent/0.png");
|
||||
case 6 -> assets.getTexture(CAM_LOC_PATH + "RightAirVent/0.png");
|
||||
case 7 -> assets.getTexture(CAM_LOC_PATH + "MainHall/0.png");
|
||||
case 8 -> assets.getTexture(CAM_LOC_PATH + "PartsnService/0.png");
|
||||
case 9 -> assets.getTexture(CAM_LOC_PATH + "ShowStage/0.png");
|
||||
case 10 -> assets.getTexture(CAM_LOC_PATH + "GameArea/2.png");
|
||||
case 11 -> assets.getTexture(CAM_LOC_PATH + "PrizeCorner/0.png");
|
||||
case 12 -> assets.getTexture(CAM_LOC_PATH + "KidsCove/0.png");
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
public void renderUI(SpriteBatch batch) {
|
||||
if (!gameScene.hud.isInsideCamera()) {return;}
|
||||
|
||||
// Border overlay
|
||||
Texture border = assets.getTexture(CAM_UTILS_PATH + "Border.png");
|
||||
batch.draw(border, 0, 0);
|
||||
|
||||
// Map
|
||||
Texture map = assets.getTexture(CAM_UTILS_PATH + "Map.png");
|
||||
batch.draw(map, 550, App.SCREEN_HEIGHT - 310 - map.getHeight());
|
||||
|
||||
// Room label
|
||||
Texture label = getRoomLabel(inCameraRoom);
|
||||
if (label != null) {
|
||||
batch.draw(label, 550, App.SCREEN_HEIGHT - 280 - label.getHeight());
|
||||
}
|
||||
|
||||
drawCameraButtons(batch);
|
||||
|
||||
// REC sprite
|
||||
if (recVisible) {
|
||||
Texture rec = assets.getTexture(CAM_UTILS_PATH + "1.png");
|
||||
batch.draw(rec, 40, App.SCREEN_HEIGHT - 40 - rec.getHeight());
|
||||
}
|
||||
|
||||
// Signal interrupted banner
|
||||
if (occupiedCamera[inCameraRoom - 1]) {
|
||||
Texture sig = assets.getTexture(CAM_UTILS_PATH + "2.png");
|
||||
float sx = Gdx.graphics.getWidth() / 2f - sig.getWidth() / 2f;
|
||||
batch.draw(sig, sx, App.SCREEN_HEIGHT - 80 - sig.getHeight());
|
||||
}
|
||||
}
|
||||
|
||||
public void renderHitboxes(ShapeRenderer shapeRenderer) {
|
||||
shapeRenderer.setColor(1, 0, 0, 1);
|
||||
for (Rectangle rect : this.buttonScreenRects){
|
||||
shapeRenderer.rect(rect.x, rect.y, rect.width, rect.height);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawCameraButtons(SpriteBatch batch) {
|
||||
Texture btnUnsel = assets.getTexture(CAM_LABELS_PATH + "13.png");
|
||||
Texture btnSel = assets.getTexture(CAM_LABELS_PATH + "14.png");
|
||||
|
||||
for (int i = 0; i < CAM_COUNT; i++) {
|
||||
Texture btn = (i + 1 == inCameraRoom) ? btnSel : btnUnsel;
|
||||
|
||||
float pyX = BTN_POS[i][0];
|
||||
float pyY = BTN_POS[i][1];
|
||||
|
||||
float worldY = App.SCREEN_HEIGHT - pyY - btnUnsel.getHeight();
|
||||
batch.draw(btn, pyX, worldY);
|
||||
|
||||
Texture lbl = assets.getTexture(CAM_LABELS_PATH + (i + 1) + ".png");
|
||||
batch.draw(lbl, pyX + 5, worldY + 7);
|
||||
}
|
||||
}
|
||||
|
||||
private Texture getRoomLabel(int room) {
|
||||
return switch (room) {
|
||||
case 1 -> assets.getTexture(CAM_LOC_PATH + "PartyRoom1/label.png");
|
||||
case 2 -> assets.getTexture(CAM_LOC_PATH + "PartyRoom2/label.png");
|
||||
case 3 -> assets.getTexture(CAM_LOC_PATH + "PartyRoom3/label.png");
|
||||
case 4 -> assets.getTexture(CAM_LOC_PATH + "PartyRoom4/label.png");
|
||||
case 5 -> assets.getTexture(CAM_LOC_PATH + "LeftAirVent/label.png");
|
||||
case 6 -> assets.getTexture(CAM_LOC_PATH + "RightAirVent/label.png");
|
||||
case 7 -> assets.getTexture(CAM_LOC_PATH + "MainHall/label.png");
|
||||
case 8 -> assets.getTexture(CAM_LOC_PATH + "PartsnService/label.png");
|
||||
case 9 -> assets.getTexture(CAM_LOC_PATH + "ShowStage/label.png");
|
||||
case 10 -> assets.getTexture(CAM_LOC_PATH + "GameArea/label.png");
|
||||
case 11 -> assets.getTexture(CAM_LOC_PATH + "PrizeCorner/label.png");
|
||||
case 12 -> assets.getTexture(CAM_LOC_PATH + "KidsCove/label.png");
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private void updateCameraTimer(int index, float delta) {
|
||||
if (index < 6) return;
|
||||
|
||||
Texture sample = getDefaultFrame(index + 1);
|
||||
if (sample == null) return;
|
||||
float maxOffset = -(sample.getWidth() - App.SCREEN_WIDTH);
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
if (timerCheckpoints[index] != 0) {
|
||||
if (now - timerCheckpoints[index] > timerWaitTimeMs[index]) {
|
||||
timerCheckpoints[index] = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (wideCameraMovDirection[index] == 0) {
|
||||
camerasXPosition[index] -= CAMERA_SPEED * delta;
|
||||
if (camerasXPosition[index] <= maxOffset) {
|
||||
camerasXPosition[index] = maxOffset;
|
||||
timerCheckpoints[index] = now;
|
||||
wideCameraMovDirection[index] = 1;
|
||||
}
|
||||
} else {
|
||||
camerasXPosition[index] += CAMERA_SPEED * delta;
|
||||
if (camerasXPosition[index] >= 0) {
|
||||
camerasXPosition[index] = 0;
|
||||
timerCheckpoints[index] = now;
|
||||
wideCameraMovDirection[index] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int getInCameraRoom() { return inCameraRoom; }
|
||||
public void setInCameraRoom(int room) { this.inCameraRoom = room; }
|
||||
public boolean isOccupied(int roomIndex) { return occupiedCamera[roomIndex]; }
|
||||
public void setOccupied(int roomIndex, boolean v) { occupiedCamera[roomIndex] = v; }
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ public class Office {
|
||||
}
|
||||
|
||||
public void update(float dt) {
|
||||
positionX += (int) (SPEED * movement * dt);
|
||||
positionX += SPEED * movement * dt;
|
||||
if (-positionX < 0) positionX = 0;
|
||||
else if (-positionX > MAX_POS_X) positionX = -MAX_POS_X;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.badlogic.gdx.graphics.glutils.FrameBuffer;
|
||||
import com.badlogic.gdx.graphics.glutils.ShaderProgram;
|
||||
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
|
||||
import io.github.eldek0.App;
|
||||
import io.github.eldek0.game.Camera;
|
||||
import io.github.eldek0.game.Mask;
|
||||
import io.github.eldek0.game.Office;
|
||||
import io.github.eldek0.ui.HUD;
|
||||
@@ -25,6 +26,7 @@ public class GameScene implements Screen {
|
||||
private final Office office;
|
||||
public final HUD hud;
|
||||
private final Mask mask;
|
||||
private final Camera camera;
|
||||
FrameBuffer frameBuffer;
|
||||
|
||||
public GameScene(App app) {
|
||||
@@ -36,6 +38,7 @@ public class GameScene implements Screen {
|
||||
this.office = new Office(this.hud);
|
||||
|
||||
this.mask = new Mask(this.hud);
|
||||
this.camera = new Camera(this);
|
||||
|
||||
frameBuffer = new FrameBuffer(Pixmap.Format.RGBA8888, App.SCREEN_WIDTH, App.SCREEN_HEIGHT, false);
|
||||
|
||||
@@ -55,11 +58,13 @@ public class GameScene implements Screen {
|
||||
office.update(v);
|
||||
hud.update(v);
|
||||
mask.update(v);
|
||||
camera.update(v);
|
||||
|
||||
frameBuffer.begin();
|
||||
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
|
||||
batch.begin();
|
||||
office.render(batch);
|
||||
camera.renderBackground(batch);
|
||||
batch.end();
|
||||
frameBuffer.end();
|
||||
|
||||
@@ -72,6 +77,7 @@ public class GameScene implements Screen {
|
||||
batch.setShader(null);
|
||||
|
||||
mask.render(batch);
|
||||
camera.renderUI(batch);
|
||||
hud.render();
|
||||
batch.end();
|
||||
|
||||
@@ -82,6 +88,8 @@ public class GameScene implements Screen {
|
||||
|
||||
hud.renderHitboxes(shapeRenderer);
|
||||
|
||||
camera.renderHitboxes(shapeRenderer);
|
||||
|
||||
shapeRenderer.end();
|
||||
|
||||
if (Gdx.input.isKeyJustPressed(Input.Keys.F5) && App.DEBUG) {
|
||||
@@ -117,7 +125,7 @@ public class GameScene implements Screen {
|
||||
public void reloadShader() {
|
||||
if (shader != null) shader.dispose();
|
||||
|
||||
String basePath = System.getProperty("user.dir") + "/assets/";
|
||||
String basePath = System.getProperty("user.dir") + "/";
|
||||
String vert = Gdx.files.internal(basePath + VERTEX_PATH).readString();
|
||||
String frag = Gdx.files.internal(basePath + FRAGMENT_PATH).readString();
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ public class Button {
|
||||
private final Animation<Texture> animation;
|
||||
private float stateTime;
|
||||
|
||||
private boolean enable = true;
|
||||
|
||||
public Button(Texture sprite, float x, float y, Rectangle bounds, Texture[] transitionFrames) {
|
||||
this.buttonTexture = sprite;
|
||||
this.x = x;
|
||||
@@ -35,6 +37,7 @@ public class Button {
|
||||
}
|
||||
|
||||
public void update(float delta) {
|
||||
if (!enable){return;}
|
||||
Vector2 position = App.convertPosToWorldPos(new Vector2(Gdx.input.getX(), Gdx.input.getY()));
|
||||
|
||||
if (bounds.contains(position)) {
|
||||
@@ -57,10 +60,12 @@ public class Button {
|
||||
}
|
||||
|
||||
public void render(SpriteBatch batch) {
|
||||
if (!enable){return;}
|
||||
batch.draw(buttonTexture, x, y, buttonTexture.getWidth(), buttonTexture.getHeight());
|
||||
}
|
||||
|
||||
public void renderHitbox(ShapeRenderer shapeRenderer) {
|
||||
if (!enable){return;}
|
||||
shapeRenderer.rect(bounds.x, bounds.y, bounds.width, bounds.height);
|
||||
}
|
||||
|
||||
@@ -89,6 +94,9 @@ public class Button {
|
||||
|
||||
}
|
||||
|
||||
public boolean isEnable() {return enable;}
|
||||
public void enable() {this.enable = true;}
|
||||
public void disable() {this.enable = false;}
|
||||
public boolean isInTransition(){return entering || quitting;}
|
||||
public boolean isInside() { return inside; }
|
||||
public boolean isBeingPressed() { return beingPressed; }
|
||||
|
||||
@@ -24,6 +24,19 @@ public class HUD {
|
||||
cameraButton.render(batch);
|
||||
|
||||
batch.end();
|
||||
|
||||
if (maskButton.isInTransition() || maskButton.isInside()){
|
||||
maskButton.enable();
|
||||
cameraButton.disable();
|
||||
}
|
||||
else if (cameraButton.isInTransition() || cameraButton.isInside()) {
|
||||
maskButton.disable();
|
||||
cameraButton.enable();
|
||||
}
|
||||
else {
|
||||
maskButton.enable();
|
||||
cameraButton.enable();
|
||||
}
|
||||
}
|
||||
|
||||
public void renderHitboxes(ShapeRenderer shapeRenderer) {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
#This file is generated by updateDaemonJvm
|
||||
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/df211d3c3eefdc408b462041881bc575/redirect
|
||||
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/93aeea858331bd6bb00ba94759830234/redirect
|
||||
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/df211d3c3eefdc408b462041881bc575/redirect
|
||||
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/b41931cf1e70bc8e08d7dd19c343ef00/redirect
|
||||
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/3426ffcaa54c3f62406beb1f1ab8b179/redirect
|
||||
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d6690dfd71c4c91e08577437b5b2beb0/redirect
|
||||
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/df211d3c3eefdc408b462041881bc575/redirect
|
||||
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/b41931cf1e70bc8e08d7dd19c343ef00/redirect
|
||||
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/1e91f45234d88a64dafb961c93ddc75a/redirect
|
||||
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/552c7bffe0370c66410a51c55985b511/redirect
|
||||
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d1cdf34033d69f8d4f43c91ee68af29f/redirect
|
||||
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/952dbdd3f95dda9dc0709bc891206f1f/redirect
|
||||
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d1cdf34033d69f8d4f43c91ee68af29f/redirect
|
||||
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/952dbdd3f95dda9dc0709bc891206f1f/redirect
|
||||
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/a278f10e4aa15951649a32f0d45debe2/redirect
|
||||
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/46db9db627f8bba1a2822054b93228f9/redirect
|
||||
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d1cdf34033d69f8d4f43c91ee68af29f/redirect
|
||||
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/952dbdd3f95dda9dc0709bc891206f1f/redirect
|
||||
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/278a765117def83915ff20a22e3aab48/redirect
|
||||
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/fe06cac13d0fc1321b8e034d546e8a06/redirect
|
||||
toolchainVendor=MICROSOFT
|
||||
toolchainVersion=21
|
||||
|
||||
Reference in New Issue
Block a user