Compare commits
2 Commits
42e49b5cd8
...
0a8cf20de9
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a8cf20de9 | |||
| 2850c26657 |
39
game/scenes/Interaction/scene_teleporter.gd
Normal file
39
game/scenes/Interaction/scene_teleporter.gd
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
extends Area3D
|
||||||
|
|
||||||
|
@export_file("*.tscn") var target_scene_path := "res://scenes/Levels/transportation_level.tscn"
|
||||||
|
@export var target_group: StringName = &"player"
|
||||||
|
@export var one_shot := true
|
||||||
|
|
||||||
|
var _is_transitioning := false
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
body_entered.connect(_on_body_entered)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_body_entered(body: Node) -> void:
|
||||||
|
if _is_transitioning:
|
||||||
|
return
|
||||||
|
if target_group != StringName() and not body.is_in_group(target_group):
|
||||||
|
return
|
||||||
|
if target_scene_path.strip_edges() == "":
|
||||||
|
push_warning("Teleporter target scene is empty.")
|
||||||
|
return
|
||||||
|
if not ResourceLoader.exists(target_scene_path):
|
||||||
|
push_warning("Teleporter target scene does not exist: %s" % target_scene_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
_is_transitioning = true
|
||||||
|
if one_shot:
|
||||||
|
set_deferred("monitoring", false)
|
||||||
|
call_deferred("_deferred_change_scene")
|
||||||
|
|
||||||
|
|
||||||
|
func _deferred_change_scene() -> void:
|
||||||
|
var err := get_tree().change_scene_to_file(target_scene_path)
|
||||||
|
if err == OK:
|
||||||
|
return
|
||||||
|
push_warning("Failed to change scene to '%s' (%s)." % [target_scene_path, err])
|
||||||
|
_is_transitioning = false
|
||||||
|
if one_shot:
|
||||||
|
set_deferred("monitoring", true)
|
||||||
1
game/scenes/Interaction/scene_teleporter.gd.uid
Normal file
1
game/scenes/Interaction/scene_teleporter.gd.uid
Normal file
@ -0,0 +1 @@
|
|||||||
|
uid://dyvldjfan2beq
|
||||||
32
game/scenes/Interaction/scene_teleporter.tscn
Normal file
32
game/scenes/Interaction/scene_teleporter.tscn
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
[gd_scene load_steps=4 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scenes/Interaction/scene_teleporter.gd" id="1_tele"]
|
||||||
|
|
||||||
|
[sub_resource type="CylinderShape3D" id="CylinderShape3D_tele"]
|
||||||
|
height = 3.0
|
||||||
|
radius = 1.5
|
||||||
|
|
||||||
|
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_tele"]
|
||||||
|
transparency = 1
|
||||||
|
shading_mode = 0
|
||||||
|
albedo_color = Color(0.15, 0.95, 1, 0.25)
|
||||||
|
emission_enabled = true
|
||||||
|
emission = Color(0.1, 0.9, 1, 1)
|
||||||
|
emission_energy_multiplier = 1.5
|
||||||
|
|
||||||
|
[sub_resource type="CylinderMesh" id="CylinderMesh_tele"]
|
||||||
|
material = SubResource("StandardMaterial3D_tele")
|
||||||
|
top_radius = 1.6
|
||||||
|
bottom_radius = 1.6
|
||||||
|
height = 3.0
|
||||||
|
|
||||||
|
[node name="SceneTeleporter" type="Area3D"]
|
||||||
|
collision_layer = 2
|
||||||
|
collision_mask = 1
|
||||||
|
script = ExtResource("1_tele")
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||||
|
shape = SubResource("CylinderShape3D_tele")
|
||||||
|
|
||||||
|
[node name="Visual" type="MeshInstance3D" parent="."]
|
||||||
|
mesh = SubResource("CylinderMesh_tele")
|
||||||
@ -14,6 +14,8 @@ var time := 0.0
|
|||||||
@onready var _quest_text: RichTextLabel = $PhoneUI/Control/PhoneFrame/QuestText
|
@onready var _quest_text: RichTextLabel = $PhoneUI/Control/PhoneFrame/QuestText
|
||||||
|
|
||||||
const FIRST_QUEST_ID := "first_drive"
|
const FIRST_QUEST_ID := "first_drive"
|
||||||
|
const QUEST_PROMPT_META_PREFIX := "quest_intro_prompt_shown_"
|
||||||
|
const SPAWN_DIALOG_META_KEY := "level_spawn_dialog_shown"
|
||||||
const FIRST_QUEST := {
|
const FIRST_QUEST := {
|
||||||
"id": FIRST_QUEST_ID,
|
"id": FIRST_QUEST_ID,
|
||||||
"title": "RepoBot's First Task",
|
"title": "RepoBot's First Task",
|
||||||
@ -34,9 +36,10 @@ const FIRST_QUEST := {
|
|||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_setup_quests()
|
_setup_quests()
|
||||||
if show_spawn_dialog and DialogSystem and DialogSystem.has_method("show_text"):
|
if _should_show_spawn_dialog() and DialogSystem and DialogSystem.has_method("show_text"):
|
||||||
await get_tree().process_frame
|
await get_tree().process_frame
|
||||||
DialogSystem.show_text(spawn_dialog_text)
|
DialogSystem.show_text(spawn_dialog_text)
|
||||||
|
_mark_spawn_dialog_shown()
|
||||||
if spawn_dialog_auto_close_seconds > 0.0:
|
if spawn_dialog_auto_close_seconds > 0.0:
|
||||||
await get_tree().create_timer(spawn_dialog_auto_close_seconds).timeout
|
await get_tree().create_timer(spawn_dialog_auto_close_seconds).timeout
|
||||||
if DialogSystem and DialogSystem.has_method("close_if_text"):
|
if DialogSystem and DialogSystem.has_method("close_if_text"):
|
||||||
@ -100,8 +103,30 @@ func _show_quest_intro_dialog() -> void:
|
|||||||
var state: Dictionary = QuestManager.get_active_quest_state()
|
var state: Dictionary = QuestManager.get_active_quest_state()
|
||||||
if not bool(state.get("active", false)) or bool(state.get("completed", false)):
|
if not bool(state.get("active", false)) or bool(state.get("completed", false)):
|
||||||
return
|
return
|
||||||
|
var quest_id := String(state.get("quest_id", "")).strip_edges()
|
||||||
|
var step_id := String(state.get("current_step_id", "")).strip_edges()
|
||||||
var step_text := String(state.get("current_step_text", ""))
|
var step_text := String(state.get("current_step_text", ""))
|
||||||
if step_text.is_empty():
|
if quest_id.is_empty() or step_id.is_empty() or step_text.is_empty():
|
||||||
|
return
|
||||||
|
var prompt_key := "%s%s_%s" % [QUEST_PROMPT_META_PREFIX, quest_id, step_id]
|
||||||
|
if QuestManager.has_meta(prompt_key) and bool(QuestManager.get_meta(prompt_key)):
|
||||||
return
|
return
|
||||||
if DialogSystem and DialogSystem.has_method("show_text"):
|
if DialogSystem and DialogSystem.has_method("show_text"):
|
||||||
DialogSystem.show_text("RepoBot: New task assigned.\n\n%s" % step_text)
|
DialogSystem.show_text("RepoBot: New task assigned.\n\n%s" % step_text)
|
||||||
|
QuestManager.set_meta(prompt_key, true)
|
||||||
|
|
||||||
|
|
||||||
|
func _should_show_spawn_dialog() -> bool:
|
||||||
|
if not show_spawn_dialog:
|
||||||
|
return false
|
||||||
|
if QuestManager == null:
|
||||||
|
return true
|
||||||
|
if not QuestManager.has_meta(SPAWN_DIALOG_META_KEY):
|
||||||
|
return true
|
||||||
|
return not bool(QuestManager.get_meta(SPAWN_DIALOG_META_KEY))
|
||||||
|
|
||||||
|
|
||||||
|
func _mark_spawn_dialog_shown() -> void:
|
||||||
|
if QuestManager == null:
|
||||||
|
return
|
||||||
|
QuestManager.set_meta(SPAWN_DIALOG_META_KEY, true)
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
[gd_scene load_steps=81 format=3 uid="uid://dchj6g2i8ebph"]
|
[gd_scene load_steps=82 format=3 uid="uid://dchj6g2i8ebph"]
|
||||||
|
|
||||||
[ext_resource type="Script" uid="uid://brgmxhhhtakja" path="res://scenes/Levels/level.gd" id="1_a4mo8"]
|
[ext_resource type="Script" uid="uid://brgmxhhhtakja" path="res://scenes/Levels/level.gd" id="1_a4mo8"]
|
||||||
[ext_resource type="PackedScene" uid="uid://bb6hj6l23043x" path="res://assets/models/human.blend" id="1_eg4yq"]
|
[ext_resource type="PackedScene" uid="uid://bb6hj6l23043x" path="res://assets/models/human.blend" id="1_eg4yq"]
|
||||||
@ -10,6 +10,7 @@
|
|||||||
[ext_resource type="PackedScene" uid="uid://bnqaqbgynoyys" path="res://assets/models/TestCharAnimated.glb" id="5_fi66n"]
|
[ext_resource type="PackedScene" uid="uid://bnqaqbgynoyys" path="res://assets/models/TestCharAnimated.glb" id="5_fi66n"]
|
||||||
[ext_resource type="Script" uid="uid://bk53njt7i3kmv" path="res://scenes/Interaction/dialog_trigger_area.gd" id="6_dialog"]
|
[ext_resource type="Script" uid="uid://bk53njt7i3kmv" path="res://scenes/Interaction/dialog_trigger_area.gd" id="6_dialog"]
|
||||||
[ext_resource type="Script" uid="uid://cshtdpjp4xy2f" path="res://scenes/Quests/quest_trigger_area.gd" id="7_qtrigger"]
|
[ext_resource type="Script" uid="uid://cshtdpjp4xy2f" path="res://scenes/Quests/quest_trigger_area.gd" id="7_qtrigger"]
|
||||||
|
[ext_resource type="PackedScene" path="res://scenes/Interaction/scene_teleporter.tscn" id="8_teleporter"]
|
||||||
|
|
||||||
[sub_resource type="PhysicsMaterial" id="PhysicsMaterial_2q6dc"]
|
[sub_resource type="PhysicsMaterial" id="PhysicsMaterial_2q6dc"]
|
||||||
bounce = 0.5
|
bounce = 0.5
|
||||||
@ -609,6 +610,10 @@ scroll_active = false
|
|||||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||||
environment = SubResource("Environment_a4mo8")
|
environment = SubResource("Environment_a4mo8")
|
||||||
|
|
||||||
|
[node name="LevelExitTeleporter" parent="." instance=ExtResource("8_teleporter")]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 5.5, 0.5, 0)
|
||||||
|
target_scene_path = "res://scenes/Levels/transportation_level.tscn"
|
||||||
|
|
||||||
[connection signal="pressed" from="Menu/Control/VBoxContainer/ContinueButton" to="Menu" method="_on_continue_button_pressed"]
|
[connection signal="pressed" from="Menu/Control/VBoxContainer/ContinueButton" to="Menu" method="_on_continue_button_pressed"]
|
||||||
[connection signal="pressed" from="Menu/Control/VBoxContainer/MainMenuButton" to="Menu" method="_on_main_menu_button_pressed"]
|
[connection signal="pressed" from="Menu/Control/VBoxContainer/MainMenuButton" to="Menu" method="_on_main_menu_button_pressed"]
|
||||||
[connection signal="pressed" from="Menu/Control/VBoxContainer/QuitButton" to="Menu" method="_on_quit_button_pressed"]
|
[connection signal="pressed" from="Menu/Control/VBoxContainer/QuitButton" to="Menu" method="_on_quit_button_pressed"]
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
extends Node3D
|
|
||||||
|
|
||||||
@export var tile_size := 4.0
|
|
||||||
@export var block_height := 1.0
|
|
||||||
|
|
||||||
@onready var _block: MeshInstance3D = $TerrainBlock
|
|
||||||
@onready var _camera: Camera3D = $Camera3D
|
|
||||||
|
|
||||||
func _ready() -> void:
|
|
||||||
var coord := SelectedCharacter.get_coord()
|
|
||||||
var block_pos := Vector3(coord.x * tile_size, block_height * 0.5, coord.y * tile_size)
|
|
||||||
_block.position = block_pos
|
|
||||||
_block.scale = Vector3(tile_size, block_height, tile_size)
|
|
||||||
if _camera:
|
|
||||||
_camera.look_at(block_pos, Vector3.UP)
|
|
||||||
@ -1 +0,0 @@
|
|||||||
uid://1fico5npv6dy
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
[gd_scene load_steps=4 format=3 uid="uid://b7p7k1i4t0m2l"]
|
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://scenes/Levels/location_level.gd" id="1_6y4q1"]
|
|
||||||
|
|
||||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_yu2x4"]
|
|
||||||
albedo_color = Color(0.2, 0.6, 0.2, 1)
|
|
||||||
|
|
||||||
[sub_resource type="BoxMesh" id="BoxMesh_t2a5k"]
|
|
||||||
material = SubResource("StandardMaterial3D_yu2x4")
|
|
||||||
|
|
||||||
[node name="LocationLevel" type="Node3D"]
|
|
||||||
script = ExtResource("1_6y4q1")
|
|
||||||
|
|
||||||
[node name="TerrainBlock" type="MeshInstance3D" parent="."]
|
|
||||||
mesh = SubResource("BoxMesh_t2a5k")
|
|
||||||
|
|
||||||
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
|
|
||||||
transform = Transform3D(1, 0, 0, 0, 0.819152, 0.573576, 0, -0.573576, 0.819152, 0, 6, 0)
|
|
||||||
shadow_enabled = true
|
|
||||||
|
|
||||||
[node name="Camera3D" type="Camera3D" parent="."]
|
|
||||||
transform = Transform3D(1, 0, 0, 0, 0.92388, 0.382683, 0, -0.382683, 0.92388, 0, 6, 10)
|
|
||||||
current = true
|
|
||||||
37
game/scenes/Levels/transportation_level.gd
Normal file
37
game/scenes/Levels/transportation_level.gd
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
extends Node3D
|
||||||
|
|
||||||
|
@export var player_spawn_position := Vector3(0.0, 0.0, 0.0)
|
||||||
|
@export var day_length := 120.0
|
||||||
|
@export var start_light_angle := -90.0
|
||||||
|
|
||||||
|
@onready var _player: RigidBody3D = get_node_or_null("Player") as RigidBody3D
|
||||||
|
@onready var _sun: DirectionalLight3D = $DirectionalLight3D
|
||||||
|
|
||||||
|
var _time := 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_move_player_to_spawn()
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
_update_day_night(delta)
|
||||||
|
|
||||||
|
|
||||||
|
func _move_player_to_spawn() -> void:
|
||||||
|
if _player == null:
|
||||||
|
return
|
||||||
|
_player.global_position = player_spawn_position
|
||||||
|
_player.linear_velocity = Vector3.ZERO
|
||||||
|
_player.angular_velocity = Vector3.ZERO
|
||||||
|
|
||||||
|
|
||||||
|
func _update_day_night(delta: float) -> void:
|
||||||
|
if _sun == null or day_length <= 0.0:
|
||||||
|
return
|
||||||
|
_time = fmod(_time + delta, day_length)
|
||||||
|
var t: float = _time / day_length
|
||||||
|
var angle: float = lerp(start_light_angle, start_light_angle + 360.0, t)
|
||||||
|
_sun.rotation_degrees.x = angle
|
||||||
|
var energy_curve: float = -sin((t * TAU) + (start_light_angle * PI / 180.0))
|
||||||
|
_sun.light_energy = clamp((energy_curve * 1.0) + 0.2, 0.0, 1.2)
|
||||||
1
game/scenes/Levels/transportation_level.gd.uid
Normal file
1
game/scenes/Levels/transportation_level.gd.uid
Normal file
@ -0,0 +1 @@
|
|||||||
|
uid://c2vm651r4nepy
|
||||||
62
game/scenes/Levels/transportation_level.tscn
Normal file
62
game/scenes/Levels/transportation_level.tscn
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
[gd_scene load_steps=9 format=3 uid="uid://b7p7k1i4t0m2l"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scenes/Levels/transportation_level.gd" id="1_6y4q1"]
|
||||||
|
[ext_resource type="Script" path="res://scenes/player.gd" id="2_player"]
|
||||||
|
[ext_resource type="PackedScene" path="res://assets/models/TestCharAnimated.glb" id="3_model"]
|
||||||
|
[ext_resource type="PackedScene" path="res://scenes/Interaction/scene_teleporter.tscn" id="4_teleporter"]
|
||||||
|
|
||||||
|
[sub_resource type="SphereShape3D" id="SphereShape3D_player"]
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="BoxShape3D_ground"]
|
||||||
|
size = Vector3(1080, 2, 1080)
|
||||||
|
|
||||||
|
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_ground"]
|
||||||
|
albedo_color = Color(0.194, 0.575, 0.194, 1)
|
||||||
|
|
||||||
|
[sub_resource type="BoxMesh" id="BoxMesh_ground"]
|
||||||
|
material = SubResource("StandardMaterial3D_ground")
|
||||||
|
size = Vector3(1080, 2, 1080)
|
||||||
|
|
||||||
|
[node name="TransportationLevel" type="Node3D"]
|
||||||
|
script = ExtResource("1_6y4q1")
|
||||||
|
|
||||||
|
[node name="Ground" type="StaticBody3D" parent="."]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Ground"]
|
||||||
|
shape = SubResource("BoxShape3D_ground")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Ground"]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.00053596497, 0.0075991154, -0.0019865036)
|
||||||
|
mesh = SubResource("BoxMesh_ground")
|
||||||
|
|
||||||
|
[node name="Player" type="RigidBody3D" parent="."]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0)
|
||||||
|
script = ExtResource("2_player")
|
||||||
|
camera_path = NodePath("Camera3D")
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Player"]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)
|
||||||
|
shape = SubResource("SphereShape3D_player")
|
||||||
|
|
||||||
|
[node name="TestCharAnimated" parent="Player" instance=ExtResource("3_model")]
|
||||||
|
transform = Transform3D(-0.9998549, 0, 0.01703362, 0, 1, 0, -0.01703362, 0, -0.9998549, 0, 0, 0)
|
||||||
|
|
||||||
|
[node name="Camera3D" type="Camera3D" parent="Player"]
|
||||||
|
transform = Transform3D(0.9989785, -4.651856e-10, -0.045188628, 0.006969331, 0.9880354, 0.15407, 0.044647958, -0.15422754, 0.9870261, 0.22036135, 1.8988357, 0.64972365)
|
||||||
|
current = true
|
||||||
|
fov = 49.0
|
||||||
|
|
||||||
|
[node name="SpotLight3D" type="SpotLight3D" parent="Player"]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 0.906308, -0.422618, 0, 0.422618, 0.906308, 0, 1.7, -0.35)
|
||||||
|
visible = false
|
||||||
|
spot_range = 30.0
|
||||||
|
spot_angle = 25.0
|
||||||
|
|
||||||
|
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 0.819152, 0.573576, 0, -0.573576, 0.819152, 0, 6, 0)
|
||||||
|
shadow_enabled = true
|
||||||
|
|
||||||
|
[node name="ReturnTeleporter" parent="." instance=ExtResource("4_teleporter")]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.5, 0.5, 0)
|
||||||
|
target_scene_path = "res://scenes/Levels/level.tscn"
|
||||||
@ -110,7 +110,7 @@ func _on_select_button_pressed() -> void:
|
|||||||
|
|
||||||
var character: Dictionary = _characters[index]
|
var character: Dictionary = _characters[index]
|
||||||
SelectedCharacter.set_character(character)
|
SelectedCharacter.set_character(character)
|
||||||
get_tree().change_scene_to_file("res://scenes/Levels/location_level.tscn")
|
get_tree().change_scene_to_file("res://scenes/Levels/transportation_level.tscn")
|
||||||
|
|
||||||
func _on_refresh_button_pressed() -> void:
|
func _on_refresh_button_pressed() -> void:
|
||||||
_load_characters()
|
_load_characters()
|
||||||
|
|||||||
@ -7,6 +7,12 @@ const AUTH_LOGIN_URL := "https://pauth.ranaze.com/api/Auth/login"
|
|||||||
@onready var _login_request: HTTPRequest = %LoginRequest
|
@onready var _login_request: HTTPRequest = %LoginRequest
|
||||||
@onready var _error_label: Label = %ErrorLabel
|
@onready var _error_label: Label = %ErrorLabel
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
if not _username_input.is_connected("text_submitted", Callable(self, "_on_input_text_submitted")):
|
||||||
|
_username_input.text_submitted.connect(_on_input_text_submitted)
|
||||||
|
if not _password_input.is_connected("text_submitted", Callable(self, "_on_input_text_submitted")):
|
||||||
|
_password_input.text_submitted.connect(_on_input_text_submitted)
|
||||||
|
|
||||||
func _on_log_in_button_pressed() -> void:
|
func _on_log_in_button_pressed() -> void:
|
||||||
var username := _username_input.text.strip_edges()
|
var username := _username_input.text.strip_edges()
|
||||||
var password := _password_input.text
|
var password := _password_input.text
|
||||||
@ -46,3 +52,6 @@ func _on_back_button_pressed() -> void:
|
|||||||
|
|
||||||
func _show_error(message: String) -> void:
|
func _show_error(message: String) -> void:
|
||||||
_error_label.text = message
|
_error_label.text = message
|
||||||
|
|
||||||
|
func _on_input_text_submitted(_new_text: String) -> void:
|
||||||
|
_on_log_in_button_pressed()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user