simple player and collisions
This commit is contained in:
parent
4eed971735
commit
d04cfc425f
@ -110,6 +110,15 @@ class Debugging {
|
|||||||
ctx.closePath()
|
ctx.closePath()
|
||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
|
|
||||||
|
// Draw colliders
|
||||||
|
ctx.fillStyle = '#00aaff'
|
||||||
|
let collider = chunk.getLayer('col')
|
||||||
|
for (let i in collider.tiles) {
|
||||||
|
let p = collider.toXY(parseInt(i))
|
||||||
|
if (collider.tiles[i] === 0) continue
|
||||||
|
ctx.fillRect(p.x * chunk.tile, p.y * chunk.tile, chunk.tile, chunk.tile)
|
||||||
|
}
|
||||||
|
|
||||||
// Chunk index
|
// Chunk index
|
||||||
ctx.fillStyle = '#fff'
|
ctx.fillStyle = '#fff'
|
||||||
ctx.fillText(chunk.x + ';' + chunk.y, 5, 16)
|
ctx.fillText(chunk.x + ';' + chunk.y, 5, 16)
|
||||||
|
33
src/index.js
33
src/index.js
@ -3,6 +3,7 @@ import { canvas, ctx } from './canvas'
|
|||||||
import { TileMap, World } from './tiles'
|
import { TileMap, World } from './tiles'
|
||||||
import { HeightMap } from './heightmap'
|
import { HeightMap } from './heightmap'
|
||||||
import Debug from './debug'
|
import Debug from './debug'
|
||||||
|
import Player from './player'
|
||||||
import Input from './input'
|
import Input from './input'
|
||||||
import Viewport from './viewport'
|
import Viewport from './viewport'
|
||||||
import RES from './resource'
|
import RES from './resource'
|
||||||
@ -12,13 +13,14 @@ let frameTime = 0
|
|||||||
let frameCount = 0
|
let frameCount = 0
|
||||||
let fps = 0
|
let fps = 0
|
||||||
|
|
||||||
let vp = new Viewport(1111, 900)
|
let vp = new Viewport(0, 0)
|
||||||
|
let p = new Player(800, 1200, 32, 64)
|
||||||
|
|
||||||
let height = new HeightMap(0, 32, 16, 0)
|
let height = new HeightMap(0, 32, 16, 0)
|
||||||
let map = new TileMap('assets/ground.png', 32)
|
let map = new TileMap('assets/ground.png', 32)
|
||||||
|
|
||||||
const chunkSize = 32
|
const chunkSize = 32
|
||||||
const tileSize = 12
|
const tileSize = 16
|
||||||
|
|
||||||
// Define dirt tiles
|
// Define dirt tiles
|
||||||
map.define({
|
map.define({
|
||||||
@ -63,30 +65,8 @@ let world = new World(height, { GROUND: map }, chunkSize, tileSize, 32, 64)
|
|||||||
|
|
||||||
function update (dt) {
|
function update (dt) {
|
||||||
world.update(dt, vp)
|
world.update(dt, vp)
|
||||||
if (Input.isDown('w')) {
|
p.update(dt, vp, world)
|
||||||
vp.y -= 15
|
vp.update(dt, world)
|
||||||
} else if (Input.isDown('s')) {
|
|
||||||
vp.y += 15
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Input.isDown('a')) {
|
|
||||||
vp.x -= 15
|
|
||||||
} else if (Input.isDown('d')) {
|
|
||||||
vp.x += 15
|
|
||||||
}
|
|
||||||
|
|
||||||
let full = world.chunkSize * world.tileSize
|
|
||||||
if (vp.x < 0) {
|
|
||||||
vp.x = 0
|
|
||||||
} else if (vp.x + vp.width > world.width * full) {
|
|
||||||
vp.x = (full * world.width) - vp.width
|
|
||||||
}
|
|
||||||
|
|
||||||
if (vp.y < 0) {
|
|
||||||
vp.y = 0
|
|
||||||
} else if (vp.y + vp.height > world.height * full) {
|
|
||||||
vp.y = (full * world.height) - vp.height
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Input.mouse['btn0']) {
|
if (Input.mouse['btn0']) {
|
||||||
let mpin = world.pickMouse(vp, Input.mouse.pos)
|
let mpin = world.pickMouse(vp, Input.mouse.pos)
|
||||||
@ -103,6 +83,7 @@ function update (dt) {
|
|||||||
|
|
||||||
function draw () {
|
function draw () {
|
||||||
world.draw(vp)
|
world.draw(vp)
|
||||||
|
p.draw(vp)
|
||||||
Debug.draw(vp, world, fps)
|
Debug.draw(vp, world, fps)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@ const specialKeyMap = {
|
|||||||
'pausebreak': 19,
|
'pausebreak': 19,
|
||||||
'capslock': 20,
|
'capslock': 20,
|
||||||
'escape': 27,
|
'escape': 27,
|
||||||
|
'space': 32,
|
||||||
'pgup': 33,
|
'pgup': 33,
|
||||||
'pgdown': 34,
|
'pgdown': 34,
|
||||||
'end': 35,
|
'end': 35,
|
||||||
|
226
src/player.js
226
src/player.js
@ -1,189 +1,69 @@
|
|||||||
import { ctx } from './canvas'
|
import { ctx } from './canvas'
|
||||||
import { GameObject } from './level'
|
import Input from './input'
|
||||||
import { deg2rad, rad2vec, intersectRect } from './utils'
|
|
||||||
import RES from './resource'
|
|
||||||
|
|
||||||
const FULL_ROTATION = -85
|
class Player {
|
||||||
const FULL_ROTATION_EDGE = -FULL_ROTATION
|
constructor (x, y, w, h) {
|
||||||
|
|
||||||
class Hook extends GameObject {
|
|
||||||
constructor (player, x, y, w, h, len) {
|
|
||||||
super(x, y, w, h)
|
|
||||||
this.player = player
|
|
||||||
|
|
||||||
// Return position
|
|
||||||
this.rx = x
|
|
||||||
this.ry = y
|
|
||||||
|
|
||||||
// Hook rotation
|
|
||||||
// Hook rotation direction
|
|
||||||
this.r = 0
|
|
||||||
this.rd = 1
|
|
||||||
|
|
||||||
// Distance from center
|
|
||||||
// Moving direction
|
|
||||||
this.d = 0
|
|
||||||
this.md = -1
|
|
||||||
|
|
||||||
// Travel length
|
|
||||||
this.len = len
|
|
||||||
|
|
||||||
// Attached object
|
|
||||||
this.obj = null
|
|
||||||
}
|
|
||||||
|
|
||||||
draw () {
|
|
||||||
if (this.md !== -1) {
|
|
||||||
// Draw line
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(ctx.oX + this.rx, ctx.oY + this.ry)
|
|
||||||
ctx.lineTo(ctx.oX + this.x, ctx.oY + this.y)
|
|
||||||
ctx.stroke()
|
|
||||||
ctx.closePath()
|
|
||||||
}
|
|
||||||
|
|
||||||
let hookr = deg2rad(360 - this.r)
|
|
||||||
ctx.save()
|
|
||||||
ctx.translate(ctx.oX + this.x, ctx.oY + this.y)
|
|
||||||
ctx.rotate(hookr)
|
|
||||||
ctx.drawImage(RES.loadImage('static/hook_open.png', true), -this.w / 2, -this.h / 2, this.w, this.h)
|
|
||||||
ctx.restore()
|
|
||||||
}
|
|
||||||
|
|
||||||
clear () {
|
|
||||||
this.obj = null
|
|
||||||
this.d = 0
|
|
||||||
this.md = -1
|
|
||||||
this.x = this.rx
|
|
||||||
this.y = this.ry
|
|
||||||
}
|
|
||||||
|
|
||||||
update (level) {
|
|
||||||
if (this.d === 0 && this.r < FULL_ROTATION_EDGE && this.rd === 1) {
|
|
||||||
this.r += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.d === 0 && this.r > FULL_ROTATION && this.rd === 0) {
|
|
||||||
this.r -= 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.r >= FULL_ROTATION_EDGE && this.rd === 1) {
|
|
||||||
this.r = FULL_ROTATION_EDGE
|
|
||||||
this.rd = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.r <= FULL_ROTATION && this.rd === 0) {
|
|
||||||
this.r = FULL_ROTATION
|
|
||||||
this.rd = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctx.mouse.down['btn0'] && this.d === 0 && !this.obj) {
|
|
||||||
this.d = 0
|
|
||||||
this.md = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.md > -1) {
|
|
||||||
if (this.d > this.len && this.md === 1) {
|
|
||||||
this.md = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.d <= 2 && this.md === 0) {
|
|
||||||
this.d = 0
|
|
||||||
this.md = -1
|
|
||||||
this.x = this.rx
|
|
||||||
this.y = this.ry
|
|
||||||
|
|
||||||
// Score
|
|
||||||
if (this.obj) {
|
|
||||||
this.player.score(this.obj)
|
|
||||||
this.obj.destroy()
|
|
||||||
this.obj = null
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let dir = rad2vec(deg2rad(90 - this.r))
|
|
||||||
dir.x *= this.d
|
|
||||||
dir.y *= this.d
|
|
||||||
|
|
||||||
this.x = this.rx + dir.x
|
|
||||||
this.y = this.ry + dir.y
|
|
||||||
|
|
||||||
if (this.obj) {
|
|
||||||
this.obj.x = this.x - this.obj.w / 2
|
|
||||||
this.obj.y = this.y
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect intersection
|
|
||||||
if (this.md === 1) {
|
|
||||||
if (!this.obj) {
|
|
||||||
let firstIntersect
|
|
||||||
for (let i in level.objects) {
|
|
||||||
let obj = level.objects[i]
|
|
||||||
if (obj.physical && intersectRect(obj, this)) {
|
|
||||||
firstIntersect = obj
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (firstIntersect) {
|
|
||||||
if (firstIntersect.explode) {
|
|
||||||
let obj = firstIntersect.explode(level)
|
|
||||||
this.obj = obj
|
|
||||||
this.md = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.obj = firstIntersect
|
|
||||||
this.md = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.player.superStrength) {
|
|
||||||
this.d += 10
|
|
||||||
} else {
|
|
||||||
this.d += 5
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (this.player.superStrength) {
|
|
||||||
this.d -= 10
|
|
||||||
} else {
|
|
||||||
this.d -= this.obj ? 5 * (1 - this.obj.weight) : 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Player extends GameObject {
|
|
||||||
constructor (x, y, mh) {
|
|
||||||
super(x, y, 60, 55, '#4d4b4f')
|
|
||||||
this.x = x
|
this.x = x
|
||||||
this.y = y
|
this.y = y
|
||||||
|
|
||||||
this.hook = new Hook(this, this.x + this.w / 2, this.y + this.h, 20, 20, mh)
|
this.width = w
|
||||||
this.hook.r = FULL_ROTATION
|
this.height = h
|
||||||
|
|
||||||
this.superStrength = false
|
this.mX = 0
|
||||||
|
this.mY = 0
|
||||||
|
|
||||||
|
this.grounded = false
|
||||||
|
|
||||||
|
this.speed = 5
|
||||||
|
this.gravity = 1
|
||||||
|
this.jumpPower = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
score (object) {
|
moveAndSlide (collider) {
|
||||||
console.log('Scored', object)
|
// y collision
|
||||||
|
let oldY = this.y
|
||||||
|
this.y += this.mY
|
||||||
|
if (oldY !== this.y && collider.collide(this)) {
|
||||||
|
if (this.y > oldY) this.grounded = true
|
||||||
|
this.y = oldY
|
||||||
|
this.mY = 0
|
||||||
|
} else {
|
||||||
|
this.grounded = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// x collision
|
||||||
|
let oldX = this.x
|
||||||
|
this.x += this.mX
|
||||||
|
if (oldX !== this.x && collider.collide(this)) {
|
||||||
|
this.x = oldX
|
||||||
|
this.mX = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
draw () {
|
update (dt, vp, world) {
|
||||||
// Draw player
|
this.mY += this.gravity
|
||||||
super.draw()
|
if (Input.isDown('a')) {
|
||||||
|
this.mX = -this.speed
|
||||||
|
} else if (Input.isDown('d')) {
|
||||||
|
this.mX = this.speed
|
||||||
|
} else {
|
||||||
|
this.mX = 0
|
||||||
|
}
|
||||||
|
|
||||||
// Draw hook
|
if (this.grounded && (Input.isDown('w') || Input.isDown('space'))) {
|
||||||
this.hook.draw()
|
this.mY = -this.jumpPower
|
||||||
|
}
|
||||||
|
|
||||||
//
|
this.moveAndSlide(world)
|
||||||
|
|
||||||
|
vp.x = parseInt(this.x - vp.width / 2)
|
||||||
|
vp.y = parseInt(this.y - vp.height / 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
update (level) {
|
draw (vp) {
|
||||||
if (!level) return
|
ctx.fillStyle = '#f00'
|
||||||
this.hook.update(level)
|
ctx.fillRect(this.x - vp.x, this.y - vp.y, this.width, this.height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Player
|
||||||
|
90
src/tiles.js
90
src/tiles.js
@ -51,9 +51,8 @@ class TileMap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class TileLayer {
|
class TileLayer {
|
||||||
constructor (name, collider = false, size = 16, tileSize = 16) {
|
constructor (name, size = 16, tileSize = 16) {
|
||||||
this.name = name
|
this.name = name
|
||||||
this.collide = collider
|
|
||||||
this.size = size
|
this.size = size
|
||||||
this.tile = tileSize
|
this.tile = tileSize
|
||||||
this.tiles = []
|
this.tiles = []
|
||||||
@ -99,6 +98,61 @@ class TileLayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class TilePhysicsLayer extends TileLayer {
|
||||||
|
constructor (size = 16, tileSize = 16) {
|
||||||
|
super('col', size, tileSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
draw () {}
|
||||||
|
|
||||||
|
update (dt) {}
|
||||||
|
|
||||||
|
generateFromTiles (tiles) {
|
||||||
|
this.tiles = []
|
||||||
|
for (let i in tiles.tiles) {
|
||||||
|
let t = tiles.tiles[i]
|
||||||
|
let p = tiles.toXY(parseInt(i))
|
||||||
|
if (t === -1) {
|
||||||
|
this.tiles[i] = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this tile has neighbors that are air but its not itself air, it has a collider
|
||||||
|
let l = tiles.tileAtXY(p.x - 1, p.y)
|
||||||
|
let r = tiles.tileAtXY(p.x + 1, p.y)
|
||||||
|
let u = tiles.tileAtXY(p.x, p.y - 1)
|
||||||
|
let d = tiles.tileAtXY(p.x, p.y + 1)
|
||||||
|
if ((l == null || l !== -1) && (r == null || r !== -1) &&
|
||||||
|
(u == null || u !== -1) && (d == null || d !== -1)) {
|
||||||
|
this.tiles[i] = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tiles[i] = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
collide (chunk, obj) {
|
||||||
|
let absPos = chunk.absPos
|
||||||
|
for (let i in this.tiles) {
|
||||||
|
let t = this.tiles[i]
|
||||||
|
if (t === 0) continue
|
||||||
|
let p = this.toXY(parseInt(i))
|
||||||
|
let minX = p.x * chunk.tile + absPos.x
|
||||||
|
let minY = p.y * chunk.tile + absPos.y
|
||||||
|
let maxX = minX + chunk.tile
|
||||||
|
let maxY = minY + chunk.tile
|
||||||
|
|
||||||
|
// Intersection check
|
||||||
|
if (minX > obj.x + obj.width || maxX < obj.x ||
|
||||||
|
minY > obj.y + obj.height || maxY < obj.y) continue
|
||||||
|
|
||||||
|
return { chunk, tile: i }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class Chunk {
|
class Chunk {
|
||||||
constructor (ix, iy, size = 16, tileSize = 16) {
|
constructor (ix, iy, size = 16, tileSize = 16) {
|
||||||
this.x = ix
|
this.x = ix
|
||||||
@ -114,7 +168,9 @@ class Chunk {
|
|||||||
|
|
||||||
generateMap (tileMap, heightMap) {
|
generateMap (tileMap, heightMap) {
|
||||||
this.layers = []
|
this.layers = []
|
||||||
let fgLayer = new TileLayer('fg', true, this.size, this.tile)
|
let bgLayer = new TileLayer('bg', this.size, this.tile)
|
||||||
|
let fgLayer = new TileLayer('fg', this.size, this.tile)
|
||||||
|
let clLayer = new TilePhysicsLayer(this.size, this.tile)
|
||||||
for (let i = 0; i < this.size * this.size; i++) {
|
for (let i = 0; i < this.size * this.size; i++) {
|
||||||
let tileCoords = fgLayer.toXY(i)
|
let tileCoords = fgLayer.toXY(i)
|
||||||
let tileAbs = this.toAbs(tileCoords)
|
let tileAbs = this.toAbs(tileCoords)
|
||||||
@ -137,7 +193,11 @@ class Chunk {
|
|||||||
}
|
}
|
||||||
fgLayer.tiles.push(tileMap.indexOf('STONE'))
|
fgLayer.tiles.push(tileMap.indexOf('STONE'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clLayer.generateFromTiles(fgLayer)
|
||||||
|
this.layers.push(bgLayer)
|
||||||
this.layers.push(fgLayer)
|
this.layers.push(fgLayer)
|
||||||
|
this.layers.push(clLayer)
|
||||||
this.dirty = true
|
this.dirty = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,6 +261,10 @@ class Chunk {
|
|||||||
// Create cached image
|
// Create cached image
|
||||||
this.img = cacheFactory.capture()
|
this.img = cacheFactory.capture()
|
||||||
|
|
||||||
|
// Update collision
|
||||||
|
let cl = this.getLayer('col')
|
||||||
|
if (cl) cl.generateFromTiles(this.getLayer('fg'))
|
||||||
|
|
||||||
// Don't update again next tick
|
// Don't update again next tick
|
||||||
this.dirty = false
|
this.dirty = false
|
||||||
this._updated = true
|
this._updated = true
|
||||||
@ -212,6 +276,12 @@ class Chunk {
|
|||||||
this.layers[i].update(dt)
|
this.layers[i].update(dt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
collide (obj) {
|
||||||
|
let cl = this.getLayer('col')
|
||||||
|
if (!cl) return null
|
||||||
|
return cl.collide(this, obj)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class World {
|
class World {
|
||||||
@ -231,6 +301,7 @@ class World {
|
|||||||
this._unloadTick = 0
|
this._unloadTick = 0
|
||||||
this._lastDrawCount = 0
|
this._lastDrawCount = 0
|
||||||
this._lastUpdateCount = 0
|
this._lastUpdateCount = 0
|
||||||
|
this._collide = []
|
||||||
}
|
}
|
||||||
|
|
||||||
getChunk (x, y) {
|
getChunk (x, y) {
|
||||||
@ -242,6 +313,7 @@ class World {
|
|||||||
}
|
}
|
||||||
|
|
||||||
update (dt, vp) {
|
update (dt, vp) {
|
||||||
|
this._collide = []
|
||||||
let posPoint = vp.chunkIn(this.chunkSize * this.tileSize)
|
let posPoint = vp.chunkIn(this.chunkSize * this.tileSize)
|
||||||
for (let x = posPoint.x - 4; x < posPoint.x + 5; x++) {
|
for (let x = posPoint.x - 4; x < posPoint.x + 5; x++) {
|
||||||
for (let y = posPoint.y - 4; y < posPoint.y + 5; y++) {
|
for (let y = posPoint.y - 4; y < posPoint.y + 5; y++) {
|
||||||
@ -251,8 +323,9 @@ class World {
|
|||||||
let n = new Chunk(x, y, this.chunkSize, this.tileSize)
|
let n = new Chunk(x, y, this.chunkSize, this.tileSize)
|
||||||
n.generateMap(this.tileMaps.GROUND, this.heightMap)
|
n.generateMap(this.tileMaps.GROUND, this.heightMap)
|
||||||
this.chunks.push(n)
|
this.chunks.push(n)
|
||||||
break
|
continue
|
||||||
}
|
}
|
||||||
|
this._collide.push(exists)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -303,6 +376,15 @@ class World {
|
|||||||
this._lastDrawCount++
|
this._lastDrawCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
collide (obj) {
|
||||||
|
if (!this._collide.length) return null
|
||||||
|
for (let i in this._collide) {
|
||||||
|
let c = this._collide[i]
|
||||||
|
let collide = c.collide(obj)
|
||||||
|
if (collide) return collide
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { TileMap, Chunk, World }
|
export { TileMap, Chunk, World }
|
||||||
|
@ -23,6 +23,21 @@ class Viewport {
|
|||||||
let adj = this.adjustCentered
|
let adj = this.adjustCentered
|
||||||
return { x: Math.floor(adj.x / size), y: Math.floor(adj.y / size) }
|
return { x: Math.floor(adj.x / size), y: Math.floor(adj.y / size) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
update (dt, world) {
|
||||||
|
let full = world.chunkSize * world.tileSize
|
||||||
|
if (this.x < 0) {
|
||||||
|
this.x = 0
|
||||||
|
} else if (this.x + this.width > world.width * full) {
|
||||||
|
this.x = (full * world.width) - this.width
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.y < 0) {
|
||||||
|
this.y = 0
|
||||||
|
} else if (this.y + this.height > world.height * full) {
|
||||||
|
this.y = (full * world.height) - this.height
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Viewport
|
export default Viewport
|
||||||
|
Loading…
Reference in New Issue
Block a user