mirror of
https://github.com/EsotericSoftware/spine-runtimes.git
synced 2026-02-11 01:28:44 +08:00
Initial comment for sequence attachments (frame-by-frame).
EsotericSoftware/spine-editor#9
This commit is contained in:
parent
96a998b82d
commit
457aa3a894
@ -47,6 +47,7 @@ import com.esotericsoftware.spine.attachments.MeshAttachment;
|
||||
import com.esotericsoftware.spine.attachments.PathAttachment;
|
||||
import com.esotericsoftware.spine.attachments.PointAttachment;
|
||||
import com.esotericsoftware.spine.attachments.RegionAttachment;
|
||||
import com.esotericsoftware.spine.attachments.SequenceAttachment;
|
||||
|
||||
public class AnimationStateTests {
|
||||
final SkeletonJson json = new SkeletonJson(new AttachmentLoader() {
|
||||
@ -58,6 +59,10 @@ public class AnimationStateTests {
|
||||
return null;
|
||||
}
|
||||
|
||||
public SequenceAttachment newSequenceAttachment (Skin skin, String name, String path, int frameCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
public BoundingBoxAttachment newBoundingBoxAttachment (Skin skin, String name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -40,6 +40,7 @@ import com.esotericsoftware.spine.attachments.MeshAttachment;
|
||||
import com.esotericsoftware.spine.attachments.PathAttachment;
|
||||
import com.esotericsoftware.spine.attachments.PointAttachment;
|
||||
import com.esotericsoftware.spine.attachments.RegionAttachment;
|
||||
import com.esotericsoftware.spine.attachments.SequenceAttachment;
|
||||
|
||||
public class BonePlotting {
|
||||
static public void main (String[] args) throws Exception {
|
||||
@ -53,6 +54,10 @@ public class BonePlotting {
|
||||
return null;
|
||||
}
|
||||
|
||||
public SequenceAttachment newSequenceAttachment (Skin skin, String name, String path, int frameCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
public BoundingBoxAttachment newBoundingBoxAttachment (Skin skin, String name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -42,6 +42,7 @@ import com.esotericsoftware.spine.attachments.Attachment;
|
||||
import com.esotericsoftware.spine.attachments.MeshAttachment;
|
||||
import com.esotericsoftware.spine.attachments.PathAttachment;
|
||||
import com.esotericsoftware.spine.attachments.RegionAttachment;
|
||||
import com.esotericsoftware.spine.attachments.SequenceAttachment;
|
||||
|
||||
/** Stores the current pose for a skeleton.
|
||||
* <p>
|
||||
@ -721,6 +722,7 @@ public class Skeleton {
|
||||
int verticesLength = 0;
|
||||
float[] vertices = null;
|
||||
Attachment attachment = slot.attachment;
|
||||
if (attachment instanceof SequenceAttachment) attachment = ((SequenceAttachment)attachment).updateAttachment(slot);
|
||||
if (attachment instanceof RegionAttachment) {
|
||||
verticesLength = 8;
|
||||
vertices = temp.setSize(8);
|
||||
|
||||
@ -85,6 +85,9 @@ import com.esotericsoftware.spine.attachments.MeshAttachment;
|
||||
import com.esotericsoftware.spine.attachments.PathAttachment;
|
||||
import com.esotericsoftware.spine.attachments.PointAttachment;
|
||||
import com.esotericsoftware.spine.attachments.RegionAttachment;
|
||||
import com.esotericsoftware.spine.attachments.SequenceAttachment;
|
||||
import com.esotericsoftware.spine.attachments.SequenceAttachment.SequenceMode;
|
||||
import com.esotericsoftware.spine.attachments.TextureRegionAttachment;
|
||||
import com.esotericsoftware.spine.attachments.VertexAttachment;
|
||||
|
||||
/** Loads skeleton data in the Spine binary format.
|
||||
@ -302,7 +305,7 @@ public class SkeletonBinary extends SkeletonLoader {
|
||||
if (parent == null) throw new SerializationException("Parent mesh not found: " + linkedMesh.parent);
|
||||
linkedMesh.mesh.setDeformAttachment(linkedMesh.inheritDeform ? (VertexAttachment)parent : linkedMesh.mesh);
|
||||
linkedMesh.mesh.setParentMesh((MeshAttachment)parent);
|
||||
linkedMesh.mesh.updateUVs();
|
||||
linkedMesh.mesh.updateRegion();
|
||||
}
|
||||
linkedMeshes.clear();
|
||||
|
||||
@ -408,7 +411,7 @@ public class SkeletonBinary extends SkeletonLoader {
|
||||
region.setWidth(width * scale);
|
||||
region.setHeight(height * scale);
|
||||
Color.rgba8888ToColor(region.getColor(), color);
|
||||
region.updateOffset();
|
||||
region.updateRegion();
|
||||
return region;
|
||||
}
|
||||
case boundingbox: {
|
||||
@ -450,7 +453,7 @@ public class SkeletonBinary extends SkeletonLoader {
|
||||
mesh.setWorldVerticesLength(vertexCount << 1);
|
||||
mesh.setTriangles(triangles);
|
||||
mesh.setRegionUVs(uvs);
|
||||
mesh.updateUVs();
|
||||
mesh.updateRegion();
|
||||
mesh.setHullLength(hullLength << 1);
|
||||
if (nonessential) {
|
||||
mesh.setEdges(edges);
|
||||
@ -518,7 +521,7 @@ public class SkeletonBinary extends SkeletonLoader {
|
||||
if (nonessential) Color.rgba8888ToColor(point.getColor(), color);
|
||||
return point;
|
||||
}
|
||||
case clipping:
|
||||
case clipping: {
|
||||
int endSlotIndex = input.readInt(true);
|
||||
int vertexCount = input.readInt(true);
|
||||
Vertices vertices = readVertices(input, vertexCount);
|
||||
@ -533,6 +536,25 @@ public class SkeletonBinary extends SkeletonLoader {
|
||||
if (nonessential) Color.rgba8888ToColor(clip.getColor(), color);
|
||||
return clip;
|
||||
}
|
||||
case sequence:
|
||||
Attachment attachment = readAttachment(input, skeletonData, skin, slotIndex, attachmentName, nonessential);
|
||||
int frameCount = input.readInt(true);
|
||||
float frameTime = input.readFloat();
|
||||
SequenceMode mode = SequenceMode.values[input.readInt(true)];
|
||||
|
||||
if (attachment == null) return null;
|
||||
String path = ((TextureRegionAttachment)attachment).getPath();
|
||||
|
||||
SequenceAttachment sequence = attachmentLoader.newSequenceAttachment(skin, name, path, frameCount);
|
||||
if (sequence == null) return null;
|
||||
|
||||
sequence.setAttachment(attachment);
|
||||
sequence.setPath(path);
|
||||
sequence.setFrameCount(frameCount);
|
||||
sequence.setFrameTime(frameTime);
|
||||
sequence.setMode(mode);
|
||||
return sequence;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -323,7 +323,7 @@ public class SkeletonJson extends SkeletonLoader {
|
||||
if (parent == null) throw new SerializationException("Parent mesh not found: " + linkedMesh.parent);
|
||||
linkedMesh.mesh.setDeformAttachment(linkedMesh.inheritDeform ? (VertexAttachment)parent : linkedMesh.mesh);
|
||||
linkedMesh.mesh.setParentMesh((MeshAttachment)parent);
|
||||
linkedMesh.mesh.updateUVs();
|
||||
linkedMesh.mesh.updateRegion();
|
||||
}
|
||||
linkedMeshes.clear();
|
||||
|
||||
@ -380,7 +380,7 @@ public class SkeletonJson extends SkeletonLoader {
|
||||
String color = map.getString("color", null);
|
||||
if (color != null) Color.valueOf(color, region.getColor());
|
||||
|
||||
region.updateOffset();
|
||||
region.updateRegion();
|
||||
return region;
|
||||
}
|
||||
case boundingbox: {
|
||||
@ -416,7 +416,7 @@ public class SkeletonJson extends SkeletonLoader {
|
||||
readVertices(map, mesh, uvs.length);
|
||||
mesh.setTriangles(map.require("triangles").asShortArray());
|
||||
mesh.setRegionUVs(uvs);
|
||||
mesh.updateUVs();
|
||||
mesh.updateRegion();
|
||||
|
||||
if (map.has("hull")) mesh.setHullLength(map.require("hull").asInt() << 1);
|
||||
if (map.has("edges")) mesh.setEdges(map.require("edges").asShortArray());
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
/******************************************************************************
|
||||
* Spine Runtimes License Agreement
|
||||
* Last updated January 1, 2020. Replaces all prior versions.
|
||||
* Last updated September 24, 2021. Replaces all prior versions.
|
||||
*
|
||||
* Copyright (c) 2013-2020, Esoteric Software LLC
|
||||
* Copyright (c) 2013-2021, Esoteric Software LLC
|
||||
*
|
||||
* Integration of the Spine Runtimes into software or otherwise creating
|
||||
* derivative works of the Spine Runtimes is permitted under the terms and
|
||||
@ -37,6 +37,7 @@ import com.badlogic.gdx.utils.Array;
|
||||
public class SpringConstraint implements Updatable {
|
||||
final SpringConstraintData data;
|
||||
final Array<Bone> bones;
|
||||
// BOZO! - stiffness -> strength. stiffness, damping, rope, stretch -> move to spring.
|
||||
float mix, friction, gravity, wind, stiffness, damping;
|
||||
boolean rope, stretch;
|
||||
|
||||
|
||||
@ -63,6 +63,18 @@ public class AtlasAttachmentLoader implements AttachmentLoader {
|
||||
return attachment;
|
||||
}
|
||||
|
||||
public SequenceAttachment newSequenceAttachment (Skin skin, String name, String path, int frameCount) {
|
||||
AtlasRegion[] regions = new AtlasRegion[frameCount];
|
||||
for (int i = 0; i < frameCount; i++) {
|
||||
AtlasRegion region = atlas.findRegion(path + frameCount); // BOZO - Zero pad?
|
||||
if (region == null)
|
||||
throw new RuntimeException("Region not found in atlas: " + path + frameCount + " (sequence: " + name + ")");
|
||||
}
|
||||
SequenceAttachment sequence = new SequenceAttachment(name);
|
||||
sequence.setRegions(regions);
|
||||
return sequence;
|
||||
}
|
||||
|
||||
public BoundingBoxAttachment newBoundingBoxAttachment (Skin skin, String name) {
|
||||
return new BoundingBoxAttachment(name);
|
||||
}
|
||||
|
||||
@ -44,6 +44,9 @@ public interface AttachmentLoader {
|
||||
/** @return May be null to not load the attachment. In that case null should also be returned for child meshes. */
|
||||
public @Null MeshAttachment newMeshAttachment (Skin skin, String name, String path);
|
||||
|
||||
/** @return May be null to not load the attachment. */
|
||||
public @Null SequenceAttachment newSequenceAttachment (Skin skin, String name, String path, int frameCount);
|
||||
|
||||
/** @return May be null to not load the attachment. */
|
||||
public @Null BoundingBoxAttachment newBoundingBoxAttachment (Skin skin, String name);
|
||||
|
||||
|
||||
@ -30,7 +30,7 @@
|
||||
package com.esotericsoftware.spine.attachments;
|
||||
|
||||
public enum AttachmentType {
|
||||
region, boundingbox, mesh, linkedmesh, path, point, clipping;
|
||||
region, boundingbox, mesh, linkedmesh, path, point, clipping, sequence;
|
||||
|
||||
static public final AttachmentType[] values = values();
|
||||
}
|
||||
|
||||
@ -40,7 +40,7 @@ import com.badlogic.gdx.utils.Null;
|
||||
* supported. Each vertex has UVs (texture coordinates) and triangles are used to map an image on to the mesh.
|
||||
* <p>
|
||||
* See <a href="http://esotericsoftware.com/spine-meshes">Mesh attachments</a> in the Spine User Guide. */
|
||||
public class MeshAttachment extends VertexAttachment {
|
||||
public class MeshAttachment extends VertexAttachment implements TextureRegionAttachment {
|
||||
private TextureRegion region;
|
||||
private String path;
|
||||
private float[] regionUVs, uvs;
|
||||
@ -67,9 +67,9 @@ public class MeshAttachment extends VertexAttachment {
|
||||
return region;
|
||||
}
|
||||
|
||||
/** Calculates {@link #uvs} using {@link #regionUVs} and the {@link #region}. Must be called after changing the region UVs or
|
||||
* region. */
|
||||
public void updateUVs () {
|
||||
/** Calculates {@link #uvs} using {@link #regionUVs} and the {@link #region}. Must be called after changing the region or the
|
||||
* region's properties. */
|
||||
public void updateRegion () {
|
||||
float[] regionUVs = this.regionUVs;
|
||||
if (this.uvs == null || this.uvs.length != regionUVs.length) this.uvs = new float[regionUVs.length];
|
||||
float[] uvs = this.uvs;
|
||||
@ -152,7 +152,7 @@ public class MeshAttachment extends VertexAttachment {
|
||||
|
||||
/** The UV pair for each vertex, normalized within the entire texture.
|
||||
* <p>
|
||||
* See {@link #updateUVs}. */
|
||||
* See {@link #updateRegion()}. */
|
||||
public float[] getUVs () {
|
||||
return uvs;
|
||||
}
|
||||
@ -161,12 +161,10 @@ public class MeshAttachment extends VertexAttachment {
|
||||
this.uvs = uvs;
|
||||
}
|
||||
|
||||
/** The color to tint the mesh. */
|
||||
public Color getColor () {
|
||||
return color;
|
||||
}
|
||||
|
||||
/** The name of the texture region for this attachment. */
|
||||
public String getPath () {
|
||||
return path;
|
||||
}
|
||||
@ -269,7 +267,7 @@ public class MeshAttachment extends VertexAttachment {
|
||||
mesh.color.set(color);
|
||||
mesh.deformAttachment = deformAttachment;
|
||||
mesh.setParentMesh(parentMesh != null ? parentMesh : this);
|
||||
mesh.updateUVs();
|
||||
mesh.updateRegion();
|
||||
return mesh;
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,7 +40,7 @@ import com.esotericsoftware.spine.Bone;
|
||||
/** An attachment that displays a textured quadrilateral.
|
||||
* <p>
|
||||
* See <a href="http://esotericsoftware.com/spine-regions">Region attachments</a> in the Spine User Guide. */
|
||||
public class RegionAttachment extends Attachment {
|
||||
public class RegionAttachment extends Attachment implements TextureRegionAttachment {
|
||||
static public final int BLX = 0;
|
||||
static public final int BLY = 1;
|
||||
static public final int ULX = 2;
|
||||
@ -61,8 +61,9 @@ public class RegionAttachment extends Attachment {
|
||||
super(name);
|
||||
}
|
||||
|
||||
/** Calculates the {@link #offset} using the region settings. Must be called after changing region settings. */
|
||||
public void updateOffset () {
|
||||
/** Calculates the {@link #offset} using the {@link #region}. Must be called after changing the region or the region's
|
||||
* properties. */
|
||||
public void updateRegion () {
|
||||
float width = getWidth();
|
||||
float height = getHeight();
|
||||
float localX2 = width / 2;
|
||||
@ -137,7 +138,7 @@ public class RegionAttachment extends Attachment {
|
||||
}
|
||||
|
||||
public TextureRegion getRegion () {
|
||||
if (region == null) throw new IllegalStateException("Region has not been set: " + this);
|
||||
if (region == null) throw new IllegalStateException("Region has not been set: " + name);
|
||||
return region;
|
||||
}
|
||||
|
||||
@ -180,7 +181,7 @@ public class RegionAttachment extends Attachment {
|
||||
|
||||
/** For each of the 4 vertices, a pair of <code>x,y</code> values that is the local position of the vertex.
|
||||
* <p>
|
||||
* See {@link #updateOffset()}. */
|
||||
* See {@link #updateRegion()}. */
|
||||
public float[] getOffset () {
|
||||
return offset;
|
||||
}
|
||||
@ -252,12 +253,10 @@ public class RegionAttachment extends Attachment {
|
||||
this.height = height;
|
||||
}
|
||||
|
||||
/** The color to tint the region attachment. */
|
||||
public Color getColor () {
|
||||
return color;
|
||||
}
|
||||
|
||||
/** The name of the texture region for this attachment. */
|
||||
public String getPath () {
|
||||
return path;
|
||||
}
|
||||
|
||||
@ -0,0 +1,151 @@
|
||||
/******************************************************************************
|
||||
* Spine Runtimes License Agreement
|
||||
* Last updated September 24, 2021. Replaces all prior versions.
|
||||
*
|
||||
* Copyright (c) 2013-2021, Esoteric Software LLC
|
||||
*
|
||||
* Integration of the Spine Runtimes into software or otherwise creating
|
||||
* derivative works of the Spine Runtimes is permitted under the terms and
|
||||
* conditions of Section 2 of the Spine Editor License Agreement:
|
||||
* http://esotericsoftware.com/spine-editor-license
|
||||
*
|
||||
* Otherwise, it is permitted to integrate the Spine Runtimes into software
|
||||
* or otherwise create derivative works of the Spine Runtimes (collectively,
|
||||
* "Products"), provided that each user of the Products must obtain their own
|
||||
* Spine Editor license and redistribution of the Products in any form must
|
||||
* include this license and copyright notice.
|
||||
*
|
||||
* THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY
|
||||
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY
|
||||
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
|
||||
* BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND
|
||||
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
|
||||
* THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*****************************************************************************/
|
||||
|
||||
package com.esotericsoftware.spine.attachments;
|
||||
|
||||
import com.badlogic.gdx.graphics.g2d.TextureRegion;
|
||||
import com.badlogic.gdx.math.MathUtils;
|
||||
|
||||
import com.esotericsoftware.spine.Slot;
|
||||
|
||||
/** An attachment that applies a sequence of texture atlas regions to a region or mesh attachment.
|
||||
* <p>
|
||||
* See <a href="http://esotericsoftware.com/spine-sequences">Sequence attachments</a> in the Spine User Guide. */
|
||||
public class SequenceAttachment<T extends Attachment & TextureRegionAttachment> extends Attachment {
|
||||
private T attachment;
|
||||
private String path;
|
||||
private int frameCount;
|
||||
private float frameTime;
|
||||
private SequenceMode mode;
|
||||
private TextureRegion[] regions;
|
||||
|
||||
public SequenceAttachment (String name) {
|
||||
super(name);
|
||||
}
|
||||
|
||||
/** Updates the {@link #attachment} with the {@link #regions region} for the slot's {@link Slot#getAttachmentTime()} and
|
||||
* returns it. */
|
||||
public T updateAttachment (Slot slot) {
|
||||
int frameIndex = (int)(slot.getAttachmentTime() / frameTime);
|
||||
switch (mode) {
|
||||
case forward:
|
||||
frameIndex = Math.min(frameCount - 1, frameIndex);
|
||||
break;
|
||||
case backward:
|
||||
frameIndex = Math.max(frameCount - frameIndex - 1, 0);
|
||||
break;
|
||||
case forwardLoop:
|
||||
frameIndex = frameIndex % frameCount;
|
||||
break;
|
||||
case backwardLoop:
|
||||
frameIndex = frameCount - (frameIndex % frameCount) - 1;
|
||||
break;
|
||||
case pingPong:
|
||||
frameIndex = frameIndex % (frameCount << 1);
|
||||
if (frameIndex >= frameCount) frameIndex = frameCount - 1 - (frameIndex - frameCount);
|
||||
break;
|
||||
case random:
|
||||
frameIndex = MathUtils.random(frameCount - 1);
|
||||
}
|
||||
attachment.setRegion(regions[frameIndex]);
|
||||
attachment.updateRegion();
|
||||
return attachment;
|
||||
}
|
||||
|
||||
public void setAttachment (T attachment) {
|
||||
this.attachment = attachment;
|
||||
}
|
||||
|
||||
public T getAttachment () {
|
||||
return attachment;
|
||||
}
|
||||
|
||||
/** The prefix used to find the {@link #regions} for this attachment. */
|
||||
public String getPath () {
|
||||
return path;
|
||||
}
|
||||
|
||||
public void setPath (String path) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
public SequenceMode getMode () {
|
||||
return mode;
|
||||
}
|
||||
|
||||
public void setMode (SequenceMode mode) {
|
||||
if (mode == null) throw new IllegalArgumentException("mode cannot be null.");
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
public int getFrameCount () {
|
||||
return frameCount;
|
||||
}
|
||||
|
||||
public void setFrameCount (int frameCount) {
|
||||
this.frameCount = frameCount;
|
||||
}
|
||||
|
||||
/** The time in seconds each frame is shown. */
|
||||
public float getFrameTime () {
|
||||
return frameTime;
|
||||
}
|
||||
|
||||
public void setFrameTime (float frameTime) {
|
||||
this.frameTime = frameTime;
|
||||
}
|
||||
|
||||
public TextureRegion[] getRegions () {
|
||||
if (regions == null) throw new IllegalStateException("Regions have not been set: " + name);
|
||||
return regions;
|
||||
}
|
||||
|
||||
public void setRegions (TextureRegion[] regions) {
|
||||
if (regions == null) throw new IllegalArgumentException("regions cannot be null.");
|
||||
this.regions = regions;
|
||||
}
|
||||
|
||||
public Attachment copy () {
|
||||
SequenceAttachment copy = new SequenceAttachment(name);
|
||||
copy.attachment = attachment.copy();
|
||||
copy.path = path;
|
||||
copy.frameCount = frameCount;
|
||||
copy.frameTime = frameTime;
|
||||
copy.frameTime = frameTime;
|
||||
copy.mode = mode;
|
||||
copy.regions = regions;
|
||||
return copy;
|
||||
}
|
||||
|
||||
static public enum SequenceMode {
|
||||
forward, backward, forwardLoop, backwardLoop, pingPong, random;
|
||||
|
||||
static public final SequenceMode[] values = values();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
|
||||
package com.esotericsoftware.spine.attachments;
|
||||
|
||||
import com.badlogic.gdx.graphics.Color;
|
||||
import com.badlogic.gdx.graphics.g2d.TextureRegion;
|
||||
|
||||
public interface TextureRegionAttachment {
|
||||
/** Sets the region used to draw the attachment. If the region or its properties are changed, {@link #updateRegion()} must be
|
||||
* called. */
|
||||
public void setRegion (TextureRegion region);
|
||||
|
||||
public TextureRegion getRegion ();
|
||||
|
||||
/** Updates any values the attachment calculates using the {@link #getRegion()}. Must be called after changing the region or
|
||||
* the region's properties. */
|
||||
public void updateRegion ();
|
||||
|
||||
/** The color to tint the attachment. */
|
||||
public Color getColor ();
|
||||
|
||||
/** The name used to find the {@link #getRegion()}. */
|
||||
public String getPath ();
|
||||
|
||||
public void setPath (String path);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user