feat(mvp): complete M13 clean-up milestone (real usernames, avatar studio redesign, pause menu, audio manager)
checkpoint / checkpoint (push) Successful in 6m22s

This commit is contained in:
2026-09-18 21:54:01 +00:00
parent 4c8a60fbc8
commit ee4006d217
31 changed files with 2394 additions and 262 deletions
+38 -5
View File
@@ -46,7 +46,8 @@ DbDep = Annotated[AsyncSession, Depends(get_db)]
class FriendRequestCreate(BaseModel): class FriendRequestCreate(BaseModel):
user_id: int user_id: int | None = None
username: str | None = None
class FriendRequestResponse(BaseModel): class FriendRequestResponse(BaseModel):
@@ -55,6 +56,7 @@ class FriendRequestResponse(BaseModel):
addressee_id: int addressee_id: int
status: FriendshipStatus status: FriendshipStatus
created_at: datetime created_at: datetime
requester_username: str | None = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -132,7 +134,18 @@ async def _get_user_or_404(db: AsyncSession, user_id: int) -> User:
@router.post("/requests", response_model=FriendRequestResponse, status_code=status.HTTP_201_CREATED) @router.post("/requests", response_model=FriendRequestResponse, status_code=status.HTTP_201_CREATED)
async def send_friend_request(body: FriendRequestCreate, current_user: CurrentUser, db: DbDep): async def send_friend_request(body: FriendRequestCreate, current_user: CurrentUser, db: DbDep):
"""Send a friend request to another user.""" """Send a friend request to another user."""
target_id = body.user_id target_id: int
if body.user_id is not None and body.user_id > 0:
target_id = body.user_id
elif body.username is not None and body.username.strip() != "":
u_result = await db.execute(select(User).where(User.username == body.username.strip()))
user = u_result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
target_id = user.id
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="user_id or username required")
if target_id == current_user.id: if target_id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot send a friend request to yourself") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot send a friend request to yourself")
@@ -153,19 +166,39 @@ async def send_friend_request(body: FriendRequestCreate, current_user: CurrentUs
await db.rollback() await db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Friend request or friendship already exists") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Friend request or friendship already exists")
await db.refresh(fr) await db.refresh(fr)
return fr return FriendRequestResponse(
id=fr.id,
requester_id=fr.requester_id,
addressee_id=fr.addressee_id,
status=fr.status,
created_at=fr.created_at,
requester_username=current_user.username,
)
@router.get("/requests", response_model=FriendRequestListResponse) @router.get("/requests", response_model=FriendRequestListResponse)
async def list_incoming_requests(current_user: CurrentUser, db: DbDep): async def list_incoming_requests(current_user: CurrentUser, db: DbDep):
"""List pending friend requests sent *to* the authenticated user.""" """List pending friend requests sent *to* the authenticated user."""
result = await db.execute( result = await db.execute(
select(Friendship).where( select(Friendship, User.username)
.join(User, Friendship.requester_id == User.id)
.where(
Friendship.addressee_id == current_user.id, Friendship.addressee_id == current_user.id,
Friendship.status == FriendshipStatus.PENDING, Friendship.status == FriendshipStatus.PENDING,
) )
) )
return FriendRequestListResponse(requests=list(result.scalars().all())) requests = [
FriendRequestResponse(
id=fr.id,
requester_id=fr.requester_id,
addressee_id=fr.addressee_id,
status=fr.status,
created_at=fr.created_at,
requester_username=uname,
)
for fr, uname in result.all()
]
return FriendRequestListResponse(requests=requests)
@router.post("/requests/{request_id}/accept", response_model=FriendRequestResponse) @router.post("/requests/{request_id}/accept", response_model=FriendRequestResponse)
+44
View File
@@ -25,6 +25,13 @@ class UserResponse(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class UserSummary(BaseModel):
id: int
username: str
model_config = {"from_attributes": True}
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
email: EmailStr | None = None email: EmailStr | None = None
is_active: bool | None = None is_active: bool | None = None
@@ -40,6 +47,43 @@ async def get_current_user_profile(
return current_user return current_user
@router.get("/search", response_model=list[UserSummary])
async def search_users(
current_user: CurrentUser,
db: Annotated[AsyncSession, Depends(get_db)],
q: str = "",
limit: int = 20,
):
"""Search active users by username substring."""
cleaned_q = q.strip()
result = await db.execute(
select(User)
.where(
User.is_active == True,
User.username.ilike(f"%{cleaned_q}%"),
User.id != current_user.id,
)
.limit(limit)
)
return list(result.scalars().all())
@router.get("/by-username/{username}", response_model=UserSummary)
async def get_user_by_username(
username: str,
current_user: CurrentUser,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get active user by exact username."""
result = await db.execute(
select(User).where(User.username == username.strip(), User.is_active == True)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.get("/{user_id}", response_model=UserResponse) @router.get("/{user_id}", response_model=UserResponse)
async def get_user( async def get_user(
user_id: int, user_id: int,
+44
View File
@@ -127,6 +127,26 @@ class TestFriendRequestFlow:
again = client.delete(f"/api/friends/{a['id']}", headers=b["headers"]) again = client.delete(f"/api/friends/{a['id']}", headers=b["headers"])
assert again.status_code == 404 assert again.status_code == 404
def test_send_request_by_username_populates_requester_username(self):
client = TestClient(app)
a = _register(client, "fr_uname_a")
b = _register(client, "fr_uname_b")
resp = client.post(
"/api/friends/requests", json={"username": "fr_uname_b"}, headers=a["headers"]
)
assert resp.status_code == 201, resp.text
req_data = resp.json()
assert req_data["status"] == "pending"
assert req_data.get("requester_username") == "fr_uname_a"
incoming = client.get("/api/friends/requests", headers=b["headers"])
assert incoming.status_code == 200, incoming.text
reqs = incoming.json()["requests"]
matching = [r for r in reqs if r["id"] == req_data["id"]]
assert len(matching) == 1
assert matching[0]["requester_username"] == "fr_uname_a"
class TestSelfAndDuplicateRejection: class TestSelfAndDuplicateRejection:
def test_self_request_rejected(self): def test_self_request_rejected(self):
@@ -315,3 +335,27 @@ class TestDataIsolation:
assert client.get("/api/friends/following").status_code == 401 assert client.get("/api/friends/following").status_code == 401
assert client.get("/api/friends/blocked").status_code == 401 assert client.get("/api/friends/blocked").status_code == 401
assert client.post("/api/friends/requests", json={"user_id": 1}).status_code == 401 assert client.post("/api/friends/requests", json={"user_id": 1}).status_code == 401
class TestUserSearchAndLookup:
def test_search_and_lookup_by_username(self):
client = TestClient(app)
u1 = _register(client, "usr_srch_1")
u2 = _register(client, "usr_srch_2")
# Search excludes current user
search_res = client.get("/api/users/search?q=usr_srch", headers=u1["headers"])
assert search_res.status_code == 200, search_res.text
names = [u["username"] for u in search_res.json()]
assert "usr_srch_2" in names
assert "usr_srch_1" not in names
# Lookup by username
lookup_res = client.get("/api/users/by-username/usr_srch_2", headers=u1["headers"])
assert lookup_res.status_code == 200, lookup_res.text
assert lookup_res.json()["username"] == "usr_srch_2"
assert lookup_res.json()["id"] == u2["id"]
# Lookup 404 for unknown user
missing = client.get("/api/users/by-username/nonexistent_usr_xyz", headers=u1["headers"])
assert missing.status_code == 404
+1
View File
@@ -14,6 +14,7 @@ agp/api_base_url="http://127.0.0.1:8000"
[autoload] [autoload]
ApiClient="*res://scripts/api_client.gd" ApiClient="*res://scripts/api_client.gd"
AudioManager="*res://scripts/audio_manager.gd"
AvatarSystem="*res://scripts/avatar/avatar_system.gd" AvatarSystem="*res://scripts/avatar/avatar_system.gd"
Session="*res://scripts/session.gd" Session="*res://scripts/session.gd"
+3 -3
View File
@@ -99,7 +99,7 @@ theme_override_constants/separation = 8
[node name="TargetUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Requests/SendHBox"] [node name="TargetUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Requests/SendHBox"]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
placeholder_text = "Enter User ID to add..." placeholder_text = "Enter username or ID..."
[node name="SendButton" type="Button" parent="VBoxContainer/TabContainer/Requests/SendHBox"] [node name="SendButton" type="Button" parent="VBoxContainer/TabContainer/Requests/SendHBox"]
custom_minimum_size = Vector2(120, 32) custom_minimum_size = Vector2(120, 32)
@@ -128,7 +128,7 @@ theme_override_constants/separation = 8
[node name="FollowUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Following/FollowHBox"] [node name="FollowUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Following/FollowHBox"]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
placeholder_text = "Enter User ID to follow..." placeholder_text = "Enter username or ID..."
[node name="FollowButton" type="Button" parent="VBoxContainer/TabContainer/Following/FollowHBox"] [node name="FollowButton" type="Button" parent="VBoxContainer/TabContainer/Following/FollowHBox"]
custom_minimum_size = Vector2(100, 32) custom_minimum_size = Vector2(100, 32)
@@ -157,7 +157,7 @@ theme_override_constants/separation = 8
[node name="BlockUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Blocked/BlockHBox"] [node name="BlockUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Blocked/BlockHBox"]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
placeholder_text = "Enter User ID to block..." placeholder_text = "Enter username or ID..."
[node name="BlockButton" type="Button" parent="VBoxContainer/TabContainer/Blocked/BlockHBox"] [node name="BlockButton" type="Button" parent="VBoxContainer/TabContainer/Blocked/BlockHBox"]
custom_minimum_size = Vector2(100, 32) custom_minimum_size = Vector2(100, 32)
+64 -10
View File
@@ -39,6 +39,10 @@ extends Node
var _access_token: String = "" var _access_token: String = ""
var _token_type: String = "Bearer" var _token_type: String = "Bearer"
## Authenticated user identity
var current_user_id: int = 0
var current_username: String = ""
## Full Authorization header value, e.g. "Bearer eyJ...". Empty if no token. ## Full Authorization header value, e.g. "Bearer eyJ...". Empty if no token.
var bearer_token: String: var bearer_token: String:
get: get:
@@ -61,6 +65,8 @@ func set_token(access_token: String, token_type: String = "Bearer") -> void:
func clear_token() -> void: func clear_token() -> void:
_access_token = "" _access_token = ""
_token_type = "Bearer" _token_type = "Bearer"
current_user_id = 0
current_username = ""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -209,6 +215,12 @@ func login(username: String, password: String) -> Dictionary:
false false
) )
_store_token_from_result(result) _store_token_from_result(result)
if result.ok:
current_username = username
var me := await get_me()
if me.ok and me.data is Dictionary:
current_user_id = int((me.data as Dictionary).get("id", 0))
current_username = str((me.data as Dictionary).get("username", username))
return result return result
@@ -246,6 +258,12 @@ func register(
false false
) )
_store_token_from_result(result) _store_token_from_result(result)
if result.ok:
current_username = username
var me := await get_me()
if me.ok and me.data is Dictionary:
current_user_id = int((me.data as Dictionary).get("id", 0))
current_username = str((me.data as Dictionary).get("username", username))
return result return result
@@ -282,7 +300,23 @@ func health_check() -> Dictionary:
## Fetch the current user's profile from the stored JWT (requires auth). ## Fetch the current user's profile from the stored JWT (requires auth).
func get_me() -> Dictionary: func get_me() -> Dictionary:
return await request(HTTPClient.METHOD_GET, "/api/users/me", "", "application/json", true) var result := await request(HTTPClient.METHOD_GET, "/api/users/me", "", "application/json", true)
if result.ok and result.data is Dictionary:
current_user_id = int((result.data as Dictionary).get("id", 0))
current_username = str((result.data as Dictionary).get("username", current_username))
return result
## Search active users by username substring (requires auth).
func search_users(query: String, limit: int = 20) -> Dictionary:
var path := "/api/users/search?q=%s&limit=%d" % [query.uri_encode(), limit]
return await request(HTTPClient.METHOD_GET, path, "", "application/json", true)
## Get active user summary by exact username (requires auth).
func get_user_by_username(username: String) -> Dictionary:
var path := "/api/users/by-username/%s" % username.strip_edges().uri_encode()
return await request(HTTPClient.METHOD_GET, path, "", "application/json", true)
## Fetch the current user's saved avatar loadout (requires auth). ## Fetch the current user's saved avatar loadout (requires auth).
@@ -312,9 +346,19 @@ func save_avatar_loadout(loadout: Dictionary) -> Dictionary:
# Social Features: Friends, Follow & Block # Social Features: Friends, Follow & Block
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
## Send a friend request to target user_id. ## Send a friend request to target user_id (int) or username (String).
func send_friend_request(user_id: int) -> Dictionary: func send_friend_request(target: Variant) -> Dictionary:
return await SocialApiClient.send_friend_request(self, user_id) var payload: Dictionary = {}
if target is int:
payload = {"user_id": target}
elif target is String:
if (target as String).is_valid_int():
payload = {"user_id": int(target)}
else:
payload = {"username": (target as String).strip_edges()}
else:
payload = {"user_id": int(target)}
return await request(HTTPClient.METHOD_POST, "/api/friends/requests", JSON.stringify(payload), "application/json", true)
## Get pending incoming friend requests. ## Get pending incoming friend requests.
func get_friend_requests() -> Dictionary: func get_friend_requests() -> Dictionary:
@@ -336,9 +380,14 @@ func unfriend(user_id: int) -> Dictionary:
func get_friends() -> Dictionary: func get_friends() -> Dictionary:
return await SocialApiClient.get_friends(self) return await SocialApiClient.get_friends(self)
## Follow a user. ## Follow a user by ID or username.
func follow_user(user_id: int) -> Dictionary: func follow_user(target: Variant) -> Dictionary:
return await SocialApiClient.follow_user(self, user_id) if target is String and not (target as String).is_valid_int():
var lookup := await get_user_by_username((target as String).strip_edges())
if not lookup.ok:
return lookup
target = int(lookup.data["id"])
return await request(HTTPClient.METHOD_POST, "/api/friends/%d/follow" % int(target), "", "application/json", true)
## Unfollow a user. ## Unfollow a user.
func unfollow_user(user_id: int) -> Dictionary: func unfollow_user(user_id: int) -> Dictionary:
@@ -348,9 +397,14 @@ func unfollow_user(user_id: int) -> Dictionary:
func get_following() -> Dictionary: func get_following() -> Dictionary:
return await SocialApiClient.get_following(self) return await SocialApiClient.get_following(self)
## Block a user. ## Block a user by ID or username.
func block_user(user_id: int) -> Dictionary: func block_user(target: Variant) -> Dictionary:
return await SocialApiClient.block_user(self, user_id) if target is String and not (target as String).is_valid_int():
var lookup := await get_user_by_username((target as String).strip_edges())
if not lookup.ok:
return lookup
target = int(lookup.data["id"])
return await request(HTTPClient.METHOD_POST, "/api/friends/%d/block" % int(target), "", "application/json", true)
## Unblock a user. ## Unblock a user.
func unblock_user(user_id: int) -> Dictionary: func unblock_user(user_id: int) -> Dictionary:
+128
View File
@@ -0,0 +1,128 @@
extends Node
## Central Audio Manager singleton (autoload name: "AudioManager").
##
## Pre-warms an AudioStreamPlayer pool for UI and game SFX.
## Provides convenience methods for standard sound effects and
## automatically hooks UI buttons for tactile click audio feedback.
const STREAM_CLICK: AudioStream = preload("res://assets/audio/click.wav")
const STREAM_COIN: AudioStream = preload("res://assets/audio/coin.wav")
const STREAM_CHIME: AudioStream = preload("res://assets/audio/chime.wav")
const STREAM_FANFARE: AudioStream = preload("res://assets/audio/fanfare.wav")
const STREAM_BELL: AudioStream = preload("res://assets/audio/bell.wav")
const STREAM_DOOR: AudioStream = preload("res://assets/audio/door_open.wav")
const POOL_SIZE: int = 16
var _players: Array[AudioStreamPlayer] = []
var _player_index: int = 0
## Number of active AudioStreamPlayers in the pool.
var pool_size: int:
get:
return _players.size()
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
_init_pool()
var tree := get_tree()
if tree != null:
tree.node_added.connect(_on_node_added)
_hook_existing_buttons(tree.root)
func _init_pool() -> void:
if not _players.is_empty():
return
for i in range(POOL_SIZE):
var player := AudioStreamPlayer.new()
player.name = "AudioPlayer_%d" % i
player.bus = "Master"
add_child(player)
_players.append(player)
## Play an audio stream on the specified bus with volume and pitch settings.
func play_sound(stream: AudioStream, bus: String = "Master", volume_db: float = 0.0, pitch_scale: float = 1.0) -> AudioStreamPlayer:
if stream == null:
return null
if _players.is_empty():
_init_pool()
var player: AudioStreamPlayer = _players[_player_index]
_player_index = (_player_index + 1) % POOL_SIZE
if player.playing:
player.stop()
player.stream = stream
player.bus = bus
player.volume_db = volume_db
player.pitch_scale = pitch_scale
player.play()
return player
## Play UI button click with subtle random pitch variance.
func play_click(volume_db: float = 0.0, pitch_variance: float = 0.05) -> AudioStreamPlayer:
var pitch := 1.0
if pitch_variance > 0.0:
pitch += randf_range(-pitch_variance, pitch_variance)
return play_sound(STREAM_CLICK, "Master", volume_db, pitch)
## Play coin transaction sound (credits / marketplace / payouts).
func play_coin(volume_db: float = 0.0) -> AudioStreamPlayer:
return play_sound(STREAM_COIN, "Master", volume_db, 1.0)
## Play chime sound (successful world / chunk save).
func play_chime(volume_db: float = 0.0) -> AudioStreamPlayer:
return play_sound(STREAM_CHIME, "Master", volume_db, 1.0)
## Play victory / achievement fanfare.
func play_fanfare(volume_db: float = 0.0) -> AudioStreamPlayer:
return play_sound(STREAM_FANFARE, "Master", volume_db, 1.0)
## Play alert bell sound.
func play_bell(volume_db: float = 0.0) -> AudioStreamPlayer:
return play_sound(STREAM_BELL, "Master", volume_db, 1.0)
## Play door open transition sound.
func play_door(volume_db: float = 0.0) -> AudioStreamPlayer:
return play_sound(STREAM_DOOR, "Master", volume_db, 1.0)
# ---------------------------------------------------------------------------
# Universal Tactile UI Click Wiring
# ---------------------------------------------------------------------------
func _on_node_added(node: Node) -> void:
if node is BaseButton:
_hook_button(node as BaseButton)
func _hook_existing_buttons(node: Node) -> void:
if node is BaseButton:
_hook_button(node as BaseButton)
for child in node.get_children():
_hook_existing_buttons(child)
func _hook_button(btn: BaseButton) -> void:
var callable := Callable(self, "_on_button_pressed").bind(btn)
if not btn.pressed.is_connected(callable):
btn.pressed.connect(callable)
func _on_button_pressed(btn: BaseButton) -> void:
if not is_instance_valid(btn):
return
if btn.is_in_group("no_click_sound"):
return
if btn.is_visible_in_tree() and not btn.disabled:
play_click()
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -2,8 +2,9 @@ extends Control
## Full-screen host for the AvatarCustomizationUI. ## Full-screen host for the AvatarCustomizationUI.
## ##
## The UI itself (scripts/avatar/avatar_customization_ui.gd) builds its own ## The UI itself (scripts/avatar/avatar_customization_ui.gd) builds its own
## layout programmatically and emits `closed` when the player backs out; this ## layout programmatically, auto-saves on exit, and emits `closed` / `back_requested`
## thin wrapper is what makes it a navigable scene reachable from the main menu. ## when the player backs out. This thin wrapper hosts it as a navigable scene
## reachable from the main menu and handles ESC / back transitions.
const AvatarCustomizationUI := preload("res://scripts/avatar/avatar_customization_ui.gd") const AvatarCustomizationUI := preload("res://scripts/avatar/avatar_customization_ui.gd")
@@ -18,3 +19,13 @@ func _ready() -> void:
func _on_closed() -> void: func _on_closed() -> void:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn") get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled()
var ui = get_node_or_null("AvatarCustomizationUI")
if ui != null and ui.has_method("save_and_exit"):
ui.save_and_exit()
else:
_on_closed()
+6
View File
@@ -99,6 +99,12 @@ func _on_back_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn") get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled()
_on_back_pressed()
func show_message(text: String, is_error: bool = false) -> void: func show_message(text: String, is_error: bool = false) -> void:
if status_label: if status_label:
status_label.text = text status_label.text = text
+101
View File
@@ -0,0 +1,101 @@
class_name ControlsModal
extends Control
## Controls & Keybindings Guide Modal.
##
## Displays keyboard and mouse bindings clearly in-game and from the main menu.
signal closed
const CONTROLS_DATA = [
{"key": "W A S D", "action": "Move character"},
{"key": "Space", "action": "Jump"},
{"key": "Mouse", "action": "Look / Rotate camera"},
{"key": "E", "action": "Interact with objects & chests"},
{"key": "T / Enter", "action": "Open In-World Chat"},
{"key": "Tab / F1", "action": "Friends & Social Panel"},
{"key": "F2", "action": "Creator Studio / World Editor"},
{"key": "F11 / Alt+Enter", "action": "Toggle Fullscreen"},
{"key": "ESC", "action": "Pause Menu / Go Back / Close Modal"},
]
func _init() -> void:
name = "ControlsModal"
set_anchors_preset(Control.PRESET_FULL_RECT)
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
var bg := ColorRect.new()
bg.color = Color(0.04, 0.05, 0.08, 0.88)
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(bg)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(center)
var panel := PanelContainer.new()
panel.custom_minimum_size = Vector2(520, 0)
var style := StyleBoxFlat.new()
style.bg_color = Color(0.12, 0.14, 0.19, 0.98)
style.border_color = Color(0.4, 0.55, 0.8, 1.0)
style.set_border_width_all(2)
style.set_corner_radius_all(10)
style.content_margin_left = 28
style.content_margin_right = 28
style.content_margin_top = 22
style.content_margin_bottom = 22
panel.add_theme_stylebox_override("panel", style)
center.add_child(panel)
var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 16)
panel.add_child(vbox)
var title := Label.new()
title.text = "Keyboard & Mouse Controls"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", 22)
title.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0))
vbox.add_child(title)
var grid := GridContainer.new()
grid.columns = 2
grid.add_theme_constant_override("h_separation", 24)
grid.add_theme_constant_override("v_separation", 10)
vbox.add_child(grid)
for item in CONTROLS_DATA:
var key_lbl := Label.new()
key_lbl.text = item["key"]
key_lbl.add_theme_font_size_override("font_size", 14)
key_lbl.add_theme_color_override("font_color", Color(0.95, 0.8, 0.3))
key_lbl.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
grid.add_child(key_lbl)
var action_lbl := Label.new()
action_lbl.text = item["action"]
action_lbl.add_theme_font_size_override("font_size", 14)
action_lbl.add_theme_color_override("font_color", Color(0.85, 0.9, 0.95))
action_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
grid.add_child(action_lbl)
var close_btn := Button.new()
close_btn.text = "Close (ESC)"
close_btn.custom_minimum_size = Vector2(140, 36)
close_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
close_btn.pressed.connect(_on_close_pressed)
vbox.add_child(close_btn)
func _on_close_pressed() -> void:
closed.emit()
queue_free()
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled()
_on_close_pressed()
+11
View File
@@ -399,3 +399,14 @@ func _on_back_pressed() -> void:
func _on_world_list_pressed() -> void: func _on_world_list_pressed() -> void:
if get_tree(): if get_tree():
get_tree().change_scene_to_file("res://scenes/world_list.tscn") get_tree().change_scene_to_file("res://scenes/world_list.tscn")
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
for child in get_children():
if child is WorldBriefingModal:
child.queue_free()
get_viewport().set_input_as_handled()
return
get_viewport().set_input_as_handled()
_on_back_pressed()
+29 -2
View File
@@ -24,6 +24,7 @@ extends Control
## UI and the in-memory `ChunkEditorModel` is unaffected either way. ## UI and the in-memory `ChunkEditorModel` is unaffected either way.
signal closed signal closed
signal exit_browser_requested
var core: EditorCore var core: EditorCore
@@ -32,12 +33,17 @@ var _palette: PalettePanel
var _inspector: InspectorPanel var _inspector: InspectorPanel
var _viewport: EditorViewport var _viewport: EditorViewport
var _close_button: Button var _close_button: Button
var _exit_browser_button: Button
var _save_button: Button var _save_button: Button
var _save_as_button: Button var _save_as_button: Button
var _save_status_label: Label var _save_status_label: Label
var _instruction_label: Label var _instruction_label: Label
var _save_as_modal: Control = null var _save_as_modal: Control = null
var AudioManager: Node:
get:
return get_node_or_null("/root/AudioManager")
## Save-path state (world_persistence_impl.md §8.1/§8.2), set via ## Save-path state (world_persistence_impl.md §8.1/§8.2), set via
## `setup_persistence()`. `_save_service == null` means there is nothing to ## `setup_persistence()`. `_save_service == null` means there is nothing to
@@ -118,11 +124,18 @@ func _build_ui() -> void:
_close_button = Button.new() _close_button = Button.new()
_close_button.text = "Close (F2 / Esc)" _close_button.text = "▶ Playtest (F2)"
_close_button.tooltip_text = "Exit build mode and return to game" _close_button.tooltip_text = "Exit build mode and test world in 3D play mode (F2 / Esc)"
_close_button.add_theme_color_override("font_color", Color(0.4, 0.9, 0.5))
_close_button.pressed.connect(_on_close_pressed) _close_button.pressed.connect(_on_close_pressed)
top_bar.add_child(_close_button) top_bar.add_child(_close_button)
_exit_browser_button = Button.new()
_exit_browser_button.text = "🚪 Exit to Browser"
_exit_browser_button.tooltip_text = "Exit world and return to world browser"
_exit_browser_button.pressed.connect(_on_exit_browser_pressed)
top_bar.add_child(_exit_browser_button)
_instruction_label = Label.new() _instruction_label = Label.new()
_instruction_label.add_theme_font_size_override("font_size", 13) _instruction_label.add_theme_font_size_override("font_size", 13)
_instruction_label.add_theme_color_override("font_color", Color(0.75, 0.92, 1.0)) _instruction_label.add_theme_color_override("font_color", Color(0.75, 0.92, 1.0))
@@ -206,6 +219,12 @@ func _unhandled_key_input(event: InputEvent) -> void:
_on_tool_selected("erase") _on_tool_selected("erase")
_toolbar.set_active_tool("erase") _toolbar.set_active_tool("erase")
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
elif key.keycode == KEY_ESCAPE:
if _save_as_modal != null and is_instance_valid(_save_as_modal):
_save_as_modal.queue_free()
_save_as_modal = null
get_viewport().set_input_as_handled()
return
func _on_tool_selected(tool_id: String) -> void: func _on_tool_selected(tool_id: String) -> void:
@@ -311,6 +330,7 @@ func _on_save_pressed() -> void:
_save_button.disabled = false _save_button.disabled = false
if result.get("ok", false): if result.get("ok", false):
AudioManager.play_chime()
_base_revision = int(result.get("revision", _base_revision)) _base_revision = int(result.get("revision", _base_revision))
var msg := "Saved (revision %d)." % _base_revision var msg := "Saved (revision %d)." % _base_revision
if bool(result.get("retried", false)): if bool(result.get("retried", false)):
@@ -506,3 +526,10 @@ func _api_client() -> Node:
func _session() -> Node: func _session() -> Node:
return get_node_or_null("/root/Session") return get_node_or_null("/root/Session")
func _on_exit_browser_pressed() -> void:
exit_browser_requested.emit()
var sess := _session()
if sess != null:
sess.clear()
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
+28 -16
View File
@@ -41,6 +41,12 @@ var _friend_presence_map: Dictionary = {} # user_id (int) -> {"status": String,
func _ready() -> void: func _ready() -> void:
if send_req_input:
send_req_input.placeholder_text = "Enter username or ID..."
if follow_input:
follow_input.placeholder_text = "Enter username or ID..."
if block_input:
block_input.placeholder_text = "Enter username or ID..."
_api_client = _get_api() _api_client = _get_api()
_wire_signals() _wire_signals()
if _api_client != null and _api_client.has_token(): if _api_client != null and _api_client.has_token():
@@ -78,6 +84,12 @@ func _on_back_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn") get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled()
_on_back_pressed()
func show_message(text: String, is_error: bool = false) -> void: func show_message(text: String, is_error: bool = false) -> void:
if status_label: if status_label:
status_label.text = text status_label.text = text
@@ -254,7 +266,10 @@ func refresh_requests() -> void:
hbox.add_theme_constant_override("separation", 12) hbox.add_theme_constant_override("separation", 12)
var lbl := Label.new() var lbl := Label.new()
lbl.text = "Request from User #%d (%s)" % [from_id, created_at] var sender_name: String = str(req.get("requester_username", ""))
if sender_name.is_empty():
sender_name = "User #%d" % from_id
lbl.text = "Request from %s (%s)" % [sender_name, created_at]
lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
hbox.add_child(lbl) hbox.add_child(lbl)
@@ -292,13 +307,12 @@ func _on_decline_request(req_id: int) -> void:
func _on_send_req_pressed() -> void: func _on_send_req_pressed() -> void:
var target_text := send_req_input.text.strip_edges() var target_text := send_req_input.text.strip_edges()
if not target_text.is_valid_int(): if target_text.is_empty():
show_message("Enter a valid integer user ID.", true) show_message("Enter a username or user ID.", true)
return return
var target_id := int(target_text) var res: Dictionary = await _api_client.send_friend_request(target_text)
var res: Dictionary = await _api_client.send_friend_request(target_id)
if res.ok: if res.ok:
show_message("Friend request sent to User #%d!" % target_id) show_message("Friend request sent to %s!" % target_text)
send_req_input.text = "" send_req_input.text = ""
else: else:
show_message("Failed to send request: " + res.detail, true) show_message("Failed to send request: " + res.detail, true)
@@ -357,13 +371,12 @@ func _on_unfollow(uid: int) -> void:
func _on_follow_pressed() -> void: func _on_follow_pressed() -> void:
var target_text := follow_input.text.strip_edges() var target_text := follow_input.text.strip_edges()
if not target_text.is_valid_int(): if target_text.is_empty():
show_message("Enter a valid integer user ID.", true) show_message("Enter a username or user ID.", true)
return return
var target_id := int(target_text) var res: Dictionary = await _api_client.follow_user(target_text)
var res: Dictionary = await _api_client.follow_user(target_id)
if res.ok: if res.ok:
show_message("Following User #%d." % target_id) show_message("Following %s." % target_text)
follow_input.text = "" follow_input.text = ""
refresh_following() refresh_following()
else: else:
@@ -423,13 +436,12 @@ func _on_unblock(uid: int) -> void:
func _on_block_pressed() -> void: func _on_block_pressed() -> void:
var target_text := block_input.text.strip_edges() var target_text := block_input.text.strip_edges()
if not target_text.is_valid_int(): if target_text.is_empty():
show_message("Enter a valid integer user ID.", true) show_message("Enter a username or user ID.", true)
return return
var target_id := int(target_text) var res: Dictionary = await _api_client.block_user(target_text)
var res: Dictionary = await _api_client.block_user(target_id)
if res.ok: if res.ok:
show_message("Blocked User #%d." % target_id) show_message("Blocked %s." % target_text)
block_input.text = "" block_input.text = ""
refresh_blocked() refresh_blocked()
refresh_friends() refresh_friends()
+175 -25
View File
@@ -36,11 +36,15 @@ var _status_label: Label = null
## normal play"). Editing is in-memory only here; the save path to the ## normal play"). Editing is in-memory only here; the save path to the
## backend is a sibling task (world_persistence_impl.md). ## backend is a sibling task (world_persistence_impl.md).
const ReportModal = preload("res://scripts/report_modal.gd") const ReportModal = preload("res://scripts/report_modal.gd")
const PauseMenu = preload("res://scripts/pause_menu.gd")
const ControlsModal = preload("res://scripts/controls_modal.gd")
var _build_editor: InGameEditor = null var _build_editor: InGameEditor = null
var _chat_overlay: Control = null var _chat_overlay: Control = null
var _friends_overlay: Control = null var _friends_overlay: Control = null
var _report_modal: Control = null var _report_modal: Control = null
var _pause_menu: Control = null
var _controls_modal: Control = null
var _hud_layer: CanvasLayer = null var _hud_layer: CanvasLayer = null
var _overlay_layer: CanvasLayer = null var _overlay_layer: CanvasLayer = null
@@ -51,13 +55,13 @@ var _loaded_chunk_coords: Array[Vector3i] = []
func _ready() -> void: func _ready() -> void:
_show_build_stamp() _show_build_stamp()
_setup_in_game_hud()
_setup_chunk_streaming()
if Session.editing: if Session.editing:
_open_editor_for_world() _open_editor_for_world()
else: else:
_setup_in_game_hud()
if Session.active: if Session.active:
_setup_networked() _setup_networked()
_setup_chunk_streaming()
func _setup_chunk_streaming() -> void: func _setup_chunk_streaming() -> void:
@@ -175,26 +179,22 @@ func _hide_default_test_props() -> void:
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo: if event is InputEventKey and event.pressed and not event.echo:
if event.keycode == KEY_T or (event.keycode == KEY_ENTER and not (_chat_overlay != null and is_instance_valid(_chat_overlay))): if event.keycode == KEY_T or (event.keycode == KEY_ENTER and not (_chat_overlay != null and is_instance_valid(_chat_overlay))):
if not Session.editing and not (_build_editor != null and is_instance_valid(_build_editor)): if not (_build_editor != null and is_instance_valid(_build_editor)) and not (_pause_menu != null and is_instance_valid(_pause_menu)):
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
_toggle_chat_overlay() _toggle_chat_overlay()
return return
elif event.keycode == KEY_TAB or event.keycode == KEY_F1: elif event.keycode == KEY_TAB or event.keycode == KEY_F1:
if not Session.editing and not (_build_editor != null and is_instance_valid(_build_editor)): if not (_build_editor != null and is_instance_valid(_build_editor)) and not (_pause_menu != null and is_instance_valid(_pause_menu)):
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
_toggle_friends_overlay() _toggle_friends_overlay()
return return
if event.is_action_pressed("toggle_build_mode"): if event.is_action_pressed("toggle_build_mode"):
# world_persistence_impl.md §6 D4: the non-joined "Edit world" entry get_viewport().set_input_as_handled()
# already has its editor open from _ready() -- F2 here is the _toggle_build_mode()
# scratch/in-session build mode's own toggle and doesn't apply.
if not Session.editing:
get_viewport().set_input_as_handled()
_toggle_build_mode()
return return
# ESC leaves the world back to the browser (or closes an open overlay/editor). # ESC opens Pause Menu (or closes open modal/overlay/editor).
if event.is_action_pressed("ui_cancel"): if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
if _report_modal != null and is_instance_valid(_report_modal): if _report_modal != null and is_instance_valid(_report_modal):
@@ -203,21 +203,19 @@ func _unhandled_input(event: InputEvent) -> void:
_close_chat_overlay() _close_chat_overlay()
elif _friends_overlay != null and is_instance_valid(_friends_overlay): elif _friends_overlay != null and is_instance_valid(_friends_overlay):
_close_friends_overlay() _close_friends_overlay()
elif Session.editing: elif _controls_modal != null and is_instance_valid(_controls_modal):
# Non-joined editing has no play session to fall back into -- _close_controls_modal()
# closing it means leaving, same as the Close button. elif _pause_menu != null and is_instance_valid(_pause_menu):
_exit_editor_to_browser() _close_pause_menu()
elif _build_editor != null and is_instance_valid(_build_editor): elif _build_editor != null and is_instance_valid(_build_editor):
_close_build_mode() _close_build_mode()
else: else:
_leave_world() _open_pause_menu()
# ─── In-Game HUD & Social Overlays ─────────────────────────────────────────── # ─── In-Game HUD & Social Overlays ───────────────────────────────────────────
func _setup_in_game_hud() -> void: func _setup_in_game_hud() -> void:
if Session.editing:
return
if _hud_layer != null and is_instance_valid(_hud_layer): if _hud_layer != null and is_instance_valid(_hud_layer):
return return
_hud_layer = CanvasLayer.new() _hud_layer = CanvasLayer.new()
@@ -274,13 +272,69 @@ func _setup_in_game_hud() -> void:
_close_chat_overlay() _close_chat_overlay()
elif _friends_overlay != null and is_instance_valid(_friends_overlay): elif _friends_overlay != null and is_instance_valid(_friends_overlay):
_close_friends_overlay() _close_friends_overlay()
elif _controls_modal != null and is_instance_valid(_controls_modal):
_close_controls_modal()
elif _pause_menu != null and is_instance_valid(_pause_menu):
_close_pause_menu()
elif _build_editor != null and is_instance_valid(_build_editor): elif _build_editor != null and is_instance_valid(_build_editor):
_close_build_mode() _close_build_mode()
else: else:
_leave_world() _open_pause_menu()
) )
bar.add_child(exit_btn) bar.add_child(exit_btn)
# Bottom controls pill hint
var controls_pill := PanelContainer.new()
controls_pill.name = "ControlsPill"
var pill_style := StyleBoxFlat.new()
pill_style.bg_color = Color(0.08, 0.10, 0.14, 0.75)
pill_style.set_corner_radius_all(14)
pill_style.content_margin_left = 14
pill_style.content_margin_right = 14
pill_style.content_margin_top = 4
pill_style.content_margin_bottom = 4
controls_pill.add_theme_stylebox_override("panel", pill_style)
controls_pill.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
controls_pill.offset_top = -34
controls_pill.offset_bottom = -8
controls_pill.offset_left = 220
controls_pill.offset_right = -220
var hint_lbl := Label.new()
hint_lbl.text = "[W][A][S][D] Move [Space] Jump [E] Interact [F1] Friends [T] Chat [F2] Build [ESC] Menu"
hint_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
hint_lbl.add_theme_font_size_override("font_size", 12)
hint_lbl.add_theme_color_override("font_color", Color(0.85, 0.9, 0.95, 0.85))
controls_pill.add_child(hint_lbl)
_hud_layer.add_child(controls_pill)
# If editing in creator test mode, show status badge
if Session.editing and Session.world_id != null and int(Session.world_id) > 0:
var creator_pill := PanelContainer.new()
creator_pill.name = "CreatorTestPill"
var c_style := StyleBoxFlat.new()
c_style.bg_color = Color(0.18, 0.14, 0.04, 0.85)
c_style.border_color = Color(0.9, 0.75, 0.2, 0.9)
c_style.set_border_width_all(1)
c_style.set_corner_radius_all(6)
c_style.content_margin_left = 10
c_style.content_margin_right = 10
c_style.content_margin_top = 4
c_style.content_margin_bottom = 4
creator_pill.add_theme_stylebox_override("panel", c_style)
creator_pill.set_anchors_preset(Control.PRESET_TOP_RIGHT)
creator_pill.offset_left = -280
creator_pill.offset_top = 16
creator_pill.offset_right = -20
creator_pill.offset_bottom = 46
var creator_lbl := Label.new()
creator_lbl.text = "🛠️ Creator Test Mode (World #%s)" % str(Session.world_id)
creator_lbl.add_theme_font_size_override("font_size", 12)
creator_lbl.add_theme_color_override("font_color", Color(1.0, 0.9, 0.4))
creator_pill.add_child(creator_lbl)
_hud_layer.add_child(creator_pill)
func _toggle_report_modal() -> void: func _toggle_report_modal() -> void:
if _report_modal != null and is_instance_valid(_report_modal): if _report_modal != null and is_instance_valid(_report_modal):
@@ -451,6 +505,90 @@ func _close_friends_overlay() -> void:
_player._refresh_mouse_mode() _player._refresh_mouse_mode()
func _toggle_pause_menu() -> void:
if _pause_menu != null and is_instance_valid(_pause_menu):
_close_pause_menu()
else:
_open_pause_menu()
func _open_pause_menu() -> void:
if _chat_overlay != null and is_instance_valid(_chat_overlay):
_close_chat_overlay()
if _friends_overlay != null and is_instance_valid(_friends_overlay):
_close_friends_overlay()
if _controls_modal != null and is_instance_valid(_controls_modal):
_close_controls_modal()
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_player.set_physics_process(false)
_player.set_process_unhandled_input(false)
if _overlay_layer == null or not is_instance_valid(_overlay_layer):
_overlay_layer = CanvasLayer.new()
_overlay_layer.name = "OverlayLayer"
_overlay_layer.layer = 20
add_child(_overlay_layer)
var can_edit := Session.editing or (Session.world_id != null and int(Session.world_id) > 0)
var title_str := "World #%s" % str(Session.world_id) if Session.world_id != null and int(Session.world_id) > 0 else "Sandbox World"
_pause_menu = PauseMenu.new(can_edit, title_str)
_pause_menu.resume_requested.connect(_close_pause_menu)
_pause_menu.edit_world_requested.connect(func():
_close_pause_menu()
_open_build_mode()
)
_pause_menu.friends_requested.connect(func():
_close_pause_menu()
_open_friends_overlay()
)
_pause_menu.chat_requested.connect(func():
_close_pause_menu()
_open_chat_overlay()
)
_pause_menu.controls_requested.connect(func():
_open_controls_modal()
)
_pause_menu.leave_world_confirmed.connect(func():
_close_pause_menu()
_leave_world()
)
_overlay_layer.add_child(_pause_menu)
func _close_pause_menu() -> void:
if _pause_menu != null and is_instance_valid(_pause_menu):
_pause_menu.queue_free()
_pause_menu = null
if _controls_modal != null and is_instance_valid(_controls_modal):
_controls_modal.queue_free()
_controls_modal = null
if _overlay_layer != null and is_instance_valid(_overlay_layer):
_overlay_layer.queue_free()
_overlay_layer = null
_player.set_physics_process(true)
_player.set_process_unhandled_input(true)
_player._refresh_mouse_mode()
func _open_controls_modal() -> void:
if _controls_modal != null and is_instance_valid(_controls_modal):
return
_controls_modal = ControlsModal.new()
_controls_modal.closed.connect(_close_controls_modal)
if _overlay_layer != null and is_instance_valid(_overlay_layer):
_overlay_layer.add_child(_controls_modal)
else:
add_child(_controls_modal)
func _close_controls_modal() -> void:
if _controls_modal != null and is_instance_valid(_controls_modal):
_controls_modal.queue_free()
_controls_modal = null
## Entry point into the in-game editor (docs/architecture/editor.md §7.2). ## Entry point into the in-game editor (docs/architecture/editor.md §7.2).
## Freezes the player and opens a fresh, empty in-memory chunk to edit -- ## Freezes the player and opens a fresh, empty in-memory chunk to edit --
## loading a *real* chunk from the backend for editing is the sibling ## loading a *real* chunk from the backend for editing is the sibling
@@ -507,6 +645,7 @@ func _open_build_mode() -> void:
_build_editor.setup_persistence(save_service, world_id_str, room_slug, coord, base_revision) _build_editor.setup_persistence(save_service, world_id_str, room_slug, coord, base_revision)
_build_editor.closed.connect(_close_build_mode) _build_editor.closed.connect(_close_build_mode)
_build_editor.exit_browser_requested.connect(_exit_editor_to_browser)
add_child(_build_editor) add_child(_build_editor)
@@ -578,7 +717,8 @@ func _open_editor_for_world() -> void:
_build_editor = InGameEditor.new(core) _build_editor = InGameEditor.new(core)
_build_editor.setup_persistence(save_service, world_id_str, room_slug, coord, base_revision) _build_editor.setup_persistence(save_service, world_id_str, room_slug, coord, base_revision)
_build_editor.closed.connect(_exit_editor_to_browser) _build_editor.closed.connect(_close_build_mode)
_build_editor.exit_browser_requested.connect(_exit_editor_to_browser)
add_child(_build_editor) add_child(_build_editor)
@@ -728,13 +868,23 @@ func _sync_remotes(states: Dictionary, delta: float) -> void:
var st: Dictionary = states[pid] var st: Dictionary = states[pid]
var node: Node3D = _remotes.get(pid) var node: Node3D = _remotes.get(pid)
var loadout: Variant = st.get("loadout", null) var loadout: Variant = st.get("loadout", null)
var uname: String = str(st.get("username", ""))
if uname.is_empty() and loadout is Dictionary:
uname = str(loadout.get("username", ""))
if uname.is_empty():
uname = "Player %d" % pid
if node == null: if node == null:
node = _make_remote(pid, loadout) node = _make_remote(pid, loadout, uname)
_remotes[pid] = node _remotes[pid] = node
add_child(node) add_child(node)
node.global_position = st.get("position", Vector3.ZERO) node.global_position = st.get("position", Vector3.ZERO)
elif loadout is Dictionary and not (loadout as Dictionary).is_empty() and _remote_loadouts.get(pid) != loadout: else:
_setup_remote_visual(node, pid, loadout) if loadout is Dictionary and not (loadout as Dictionary).is_empty() and _remote_loadouts.get(pid) != loadout:
_setup_remote_visual(node, pid, loadout)
var tag := node.get_node_or_null("NameTag") as Label3D
if tag != null and tag.text != uname and not uname.is_empty():
tag.text = uname
var target: Vector3 = st.get("position", node.global_position) var target: Vector3 = st.get("position", node.global_position)
node.global_position = node.global_position.lerp(target, clampf(REMOTE_LERP * delta, 0.0, 1.0)) node.global_position = node.global_position.lerp(target, clampf(REMOTE_LERP * delta, 0.0, 1.0))
@@ -756,7 +906,7 @@ func _sync_remotes(states: Dictionary, delta: float) -> void:
_remote_loadouts.erase(pid) _remote_loadouts.erase(pid)
func _make_remote(pid: int, loadout: Variant = null) -> Node3D: func _make_remote(pid: int, loadout: Variant = null, uname: String = "") -> Node3D:
var root := Node3D.new() var root := Node3D.new()
root.name = "Remote_%d" % pid root.name = "Remote_%d" % pid
@@ -764,7 +914,7 @@ func _make_remote(pid: int, loadout: Variant = null) -> Node3D:
var tag := Label3D.new() var tag := Label3D.new()
tag.name = "NameTag" tag.name = "NameTag"
tag.text = "Player %d" % pid tag.text = uname if not uname.is_empty() else ("Player %d" % pid)
tag.position = Vector3(0, 2.1, 0) tag.position = Vector3(0, 2.1, 0)
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
tag.no_depth_test = true tag.no_depth_test = true
+6
View File
@@ -89,6 +89,12 @@ func _on_back_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn") get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled()
_on_back_pressed()
func show_message(text: String, is_error: bool = false) -> void: func show_message(text: String, is_error: bool = false) -> void:
if status_label: if status_label:
status_label.text = text status_label.text = text
+54 -1
View File
@@ -52,6 +52,7 @@ var _quit_button: Button
var _is_register_mode: bool = false var _is_register_mode: bool = false
var _is_authenticating: bool = false var _is_authenticating: bool = false
var _sign_out_button: Button = null
# ─── Lifecycle ──────────────────────────────────────────────────────────────── # ─── Lifecycle ────────────────────────────────────────────────────────────────
@@ -112,6 +113,18 @@ func _ready() -> void:
) )
add_child(fs_btn) add_child(fs_btn)
var ctrl_btn := Button.new()
ctrl_btn.name = "ControlsButton"
ctrl_btn.set_anchors_preset(Control.PRESET_TOP_RIGHT)
ctrl_btn.offset_left = -300
ctrl_btn.offset_top = 10
ctrl_btn.offset_right = -170
ctrl_btn.offset_bottom = 38
ctrl_btn.text = "⌨️ Controls"
ctrl_btn.add_theme_font_size_override("font_size", 12)
ctrl_btn.pressed.connect(_on_controls_button_pressed)
add_child(ctrl_btn)
# Cache node references. # Cache node references.
_auth_buttons_row = $CenterContainer/VBoxContainer/AuthButtonsRow _auth_buttons_row = $CenterContainer/VBoxContainer/AuthButtonsRow
_login_button = $CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton _login_button = $CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton
@@ -121,6 +134,8 @@ func _ready() -> void:
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit _username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
_password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit _password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit
_email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit _email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit
if _email_edit and not _email_edit.text_submitted.is_connected(_on_auth_button_pressed):
_email_edit.text_submitted.connect(_on_auth_button_pressed)
_over_18_check = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check _over_18_check = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check
_auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton _auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel _status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
@@ -198,6 +213,15 @@ func _show_authenticated(username: String) -> void:
_auth_mode_label.visible = false _auth_mode_label.visible = false
_set_nav_enabled(true) _set_nav_enabled(true)
_auth_buttons_row.visible = false _auth_buttons_row.visible = false
if _sign_out_button == null or not is_instance_valid(_sign_out_button):
_sign_out_button = Button.new()
_sign_out_button.name = "SignOutButton"
_sign_out_button.text = "Sign Out"
_sign_out_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
_sign_out_button.add_theme_font_size_override("font_size", 12)
_sign_out_button.pressed.connect(_on_sign_out_pressed)
$CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox.add_child(_sign_out_button)
_sign_out_button.visible = true
print("Restored authenticated session for: ", username) print("Restored authenticated session for: ", username)
@@ -207,6 +231,8 @@ func _reset_to_logged_out() -> void:
_auth_panel.visible = false _auth_panel.visible = false
_status_label.visible = false _status_label.visible = false
_welcome_label.visible = false _welcome_label.visible = false
if _sign_out_button != null and is_instance_valid(_sign_out_button):
_sign_out_button.visible = false
_email_edit.visible = false _email_edit.visible = false
_over_18_check.visible = false _over_18_check.visible = false
_over_18_check.button_pressed = true _over_18_check.button_pressed = true
@@ -214,6 +240,13 @@ func _reset_to_logged_out() -> void:
_update_auth_mode_ui() _update_auth_mode_ui()
func _on_sign_out_pressed() -> void:
ApiClient.clear_token()
_reset_to_logged_out()
_auth_buttons_row.visible = true
_clear_status()
# ─── Navigation ─────────────────────────────────────────────────────────────── # ─── Navigation ───────────────────────────────────────────────────────────────
func _on_discover_button_pressed() -> void: func _on_discover_button_pressed() -> void:
@@ -313,7 +346,7 @@ func _update_auth_mode_ui() -> void:
# ─── Auth submission ────────────────────────────────────────────────────────── # ─── Auth submission ──────────────────────────────────────────────────────────
func _on_auth_button_pressed() -> void: func _on_auth_button_pressed(_text: String = "") -> void:
"""Handle login or register button press.""" """Handle login or register button press."""
if _is_authenticating: if _is_authenticating:
return return
@@ -428,3 +461,23 @@ func _show_status(message: String) -> void:
func _clear_status() -> void: func _clear_status() -> void:
_status_label.text = "" _status_label.text = ""
_status_label.visible = false _status_label.visible = false
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
if has_node("ControlsModal"):
get_node("ControlsModal").queue_free()
get_viewport().set_input_as_handled()
return
if _auth_panel != null and _auth_panel.visible and not ApiClient.has_token():
_auth_panel.visible = false
_auth_buttons_row.visible = true
_clear_status()
get_viewport().set_input_as_handled()
func _on_controls_button_pressed() -> void:
if has_node("ControlsModal"):
return
var modal := preload("res://scripts/controls_modal.gd").new()
add_child(modal)
+11
View File
@@ -30,6 +30,10 @@ signal item_purchased(listing_id: int)
var _api_client: Node = null var _api_client: Node = null
var AudioManager: Node:
get:
return get_node_or_null("/root/AudioManager")
func _ready() -> void: func _ready() -> void:
_api_client = _get_api() _api_client = _get_api()
@@ -73,6 +77,12 @@ func _on_back_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn") get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled()
_on_back_pressed()
func show_status(text: String, is_error: bool = false) -> void: func show_status(text: String, is_error: bool = false) -> void:
if not status_label: if not status_label:
return return
@@ -226,6 +236,7 @@ func _on_buy_clicked(listing_id: int, item_name: String, price: int) -> void:
show_status("Purchasing '%s' for %d Buffalo Chips..." % [item_name, price]) show_status("Purchasing '%s' for %d Buffalo Chips..." % [item_name, price])
var res: Dictionary = await EconomyApiClient.purchase_listing(api, listing_id) var res: Dictionary = await EconomyApiClient.purchase_listing(api, listing_id)
if res.ok: if res.ok:
AudioManager.play_coin()
show_status("Successfully purchased '%s'! Item added to inventory." % item_name) show_status("Successfully purchased '%s'! Item added to inventory." % item_name)
item_purchased.emit(listing_id) item_purchased.emit(listing_id)
refresh_all() refresh_all()
+37 -1
View File
@@ -101,6 +101,19 @@ func _on_connected() -> void:
_server_root.add_child(_player_proxy) _server_root.add_child(_player_proxy)
connected_to_server.emit() connected_to_server.emit()
if pending_loadout.is_empty():
var api := _get_api_client()
var cur_username: String = ""
var cur_user_id: int = 0
if api != null:
cur_username = str(api.get("current_username"))
cur_user_id = int(api.get("current_user_id"))
if not cur_username.is_empty():
var default_loadout: Dictionary = {"username": cur_username}
if cur_user_id > 0:
default_loadout["user_id"] = cur_user_id
pending_loadout = default_loadout
if not pending_loadout.is_empty(): if not pending_loadout.is_empty():
_player_proxy.rpc_id(1, "rpc_set_loadout", pending_loadout) _player_proxy.rpc_id(1, "rpc_set_loadout", pending_loadout)
@@ -266,7 +279,30 @@ func send_input(move_x: float, move_z: float, jump: bool, look_yaw: float) -> vo
## Send the local player's avatar loadout to the server so it can be replicated ## Send the local player's avatar loadout to the server so it can be replicated
## to other clients. ## to other clients.
func send_loadout(loadout_dict: Dictionary) -> void: func send_loadout(loadout_dict: Dictionary) -> void:
pending_loadout = loadout_dict.duplicate(true) var copy: Dictionary = loadout_dict.duplicate(true)
var api := _get_api_client()
var cur_username: String = ""
var cur_user_id: int = 0
if api != null:
cur_username = str(api.get("current_username"))
cur_user_id = int(api.get("current_user_id"))
if not copy.has("username"):
if not cur_username.is_empty():
copy["username"] = cur_username
else:
copy["username"] = "Player_%d" % my_peer_id
if cur_user_id > 0 and not copy.has("user_id"):
copy["user_id"] = cur_user_id
pending_loadout = copy
if _player_proxy and is_instance_valid(_player_proxy): if _player_proxy and is_instance_valid(_player_proxy):
_player_proxy.rpc_id(1, "rpc_set_loadout", pending_loadout) _player_proxy.rpc_id(1, "rpc_set_loadout", pending_loadout)
func _get_api_client() -> Node:
if has_node("/root/ApiClient"):
return get_node("/root/ApiClient")
return null
+188
View File
@@ -0,0 +1,188 @@
class_name PauseMenu
extends Control
## In-game Pause & System Menu overlay.
##
## Provides resume, settings, controls guide, and safe exit confirmation.
signal resume_requested
signal edit_world_requested
signal friends_requested
signal chat_requested
signal controls_requested
signal leave_world_confirmed
var can_edit: bool = false
var world_title: String = "World"
var _confirm_box: VBoxContainer
var _main_menu_vbox: VBoxContainer
var _fs_button: Button
func _init(p_can_edit: bool = false, p_world_title: String = "World") -> void:
name = "PauseMenu"
can_edit = p_can_edit
world_title = p_world_title
set_anchors_preset(Control.PRESET_FULL_RECT)
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
# Dark semi-transparent background
var bg := ColorRect.new()
bg.color = Color(0.05, 0.07, 0.10, 0.85)
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(bg)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(center)
var panel := PanelContainer.new()
panel.custom_minimum_size = Vector2(400, 0)
var style := StyleBoxFlat.new()
style.bg_color = Color(0.12, 0.14, 0.18, 0.98)
style.border_color = Color(0.35, 0.45, 0.65, 1.0)
style.set_border_width_all(2)
style.set_corner_radius_all(10)
style.content_margin_left = 28
style.content_margin_right = 28
style.content_margin_top = 24
style.content_margin_bottom = 24
panel.add_theme_stylebox_override("panel", style)
center.add_child(panel)
var root_vbox := VBoxContainer.new()
root_vbox.add_theme_constant_override("separation", 16)
panel.add_child(root_vbox)
# Title & World info
var header_vbox := VBoxContainer.new()
header_vbox.add_theme_constant_override("separation", 4)
root_vbox.add_child(header_vbox)
var title := Label.new()
title.text = "PAUSED"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", 28)
title.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0))
header_vbox.add_child(title)
var subtitle := Label.new()
subtitle.text = world_title
subtitle.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
subtitle.add_theme_font_size_override("font_size", 14)
subtitle.add_theme_color_override("font_color", Color(0.65, 0.75, 0.9))
header_vbox.add_child(subtitle)
# Main action buttons
_main_menu_vbox = VBoxContainer.new()
_main_menu_vbox.add_theme_constant_override("separation", 10)
root_vbox.add_child(_main_menu_vbox)
var resume_btn := _make_menu_btn("▶ Resume Game (ESC)", _on_resume_pressed, Color(0.4, 0.9, 0.5))
_main_menu_vbox.add_child(resume_btn)
if can_edit:
var edit_btn := _make_menu_btn("🛠️ Creator Studio / Edit World (F2)", func(): edit_world_requested.emit())
_main_menu_vbox.add_child(edit_btn)
var friends_btn := _make_menu_btn("👥 Friends & Social (F1)", func(): friends_requested.emit())
_main_menu_vbox.add_child(friends_btn)
var chat_btn := _make_menu_btn("💬 Chat (T / Enter)", func(): chat_requested.emit())
_main_menu_vbox.add_child(chat_btn)
var controls_btn := _make_menu_btn("⌨️ Controls & Keybindings", func(): controls_requested.emit())
_main_menu_vbox.add_child(controls_btn)
_fs_button = _make_menu_btn("⛶ Toggle Fullscreen (F11)", _on_toggle_fs)
_update_fs_label()
_main_menu_vbox.add_child(_fs_button)
var leave_btn := _make_menu_btn("🚪 Leave World", _on_prompt_leave, Color(1.0, 0.45, 0.45))
_main_menu_vbox.add_child(leave_btn)
# Confirmation Box (hidden initially)
_confirm_box = VBoxContainer.new()
_confirm_box.visible = false
_confirm_box.add_theme_constant_override("separation", 14)
root_vbox.add_child(_confirm_box)
var confirm_msg := Label.new()
confirm_msg.text = "Are you sure you want to leave this world?\nAny unsaved changes will be lost."
confirm_msg.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
confirm_msg.autowrap_mode = TextServer.AUTOWRAP_WORD
confirm_msg.add_theme_font_size_override("font_size", 14)
confirm_msg.add_theme_color_override("font_color", Color(1.0, 0.85, 0.85))
_confirm_box.add_child(confirm_msg)
var confirm_btns := HBoxContainer.new()
confirm_btns.alignment = BoxContainer.ALIGNMENT_CENTER
confirm_btns.add_theme_constant_override("separation", 16)
_confirm_box.add_child(confirm_btns)
var cancel_leave_btn := Button.new()
cancel_leave_btn.text = "Stay in World"
cancel_leave_btn.custom_minimum_size = Vector2(130, 36)
cancel_leave_btn.pressed.connect(_on_cancel_leave)
confirm_btns.add_child(cancel_leave_btn)
var confirm_leave_btn := Button.new()
confirm_leave_btn.text = "Confirm Leave"
confirm_leave_btn.custom_minimum_size = Vector2(130, 36)
confirm_leave_btn.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4))
confirm_leave_btn.pressed.connect(func(): leave_world_confirmed.emit())
confirm_btns.add_child(confirm_leave_btn)
func _make_menu_btn(label: String, callback: Callable, color: Color = Color.WHITE) -> Button:
var btn := Button.new()
btn.text = label
btn.custom_minimum_size = Vector2(0, 38)
if color != Color.WHITE:
btn.add_theme_color_override("font_color", color)
btn.pressed.connect(callback)
return btn
func _on_resume_pressed() -> void:
resume_requested.emit()
func _on_toggle_fs() -> void:
Session.toggle_fullscreen()
_update_fs_label()
func _update_fs_label() -> void:
if _fs_button == null:
return
if DisplayServer.get_name() == "headless":
_fs_button.text = "⛶ Toggle Fullscreen (F11)"
return
var m := DisplayServer.window_get_mode()
if m == DisplayServer.WINDOW_MODE_FULLSCREEN or m == DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN:
_fs_button.text = "🗗 Windowed Mode (F11)"
else:
_fs_button.text = "⛶ Fullscreen Mode (F11)"
func _on_prompt_leave() -> void:
_main_menu_vbox.visible = false
_confirm_box.visible = true
func _on_cancel_leave() -> void:
_confirm_box.visible = false
_main_menu_vbox.visible = true
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled()
if _confirm_box.visible:
_on_cancel_leave()
else:
_on_resume_pressed()
+9
View File
@@ -25,6 +25,9 @@ var last_applied_seq: int = 0
## Replicated avatar loadout (base_body, slots, skin_tone). ## Replicated avatar loadout (base_body, slots, skin_tone).
var loadout: Dictionary = {} var loadout: Dictionary = {}
var username: String = ""
var user_id: int = 0
## Queue of {seq:int, move_x:float, move_z:float, jump:bool, look_yaw:float} ## Queue of {seq:int, move_x:float, move_z:float, jump:bool, look_yaw:float}
## appended by rpc_request_input, drained each physics frame. ## appended by rpc_request_input, drained each physics frame.
var _input_queue: Array[Dictionary] = [] var _input_queue: Array[Dictionary] = []
@@ -80,6 +83,8 @@ func get_server_state() -> Dictionary:
"velocity": velocity, "velocity": velocity,
"on_floor": is_on_floor(), "on_floor": is_on_floor(),
"loadout": loadout, "loadout": loadout,
"username": username,
"user_id": user_id,
} }
@@ -95,3 +100,7 @@ func rpc_set_loadout(p_loadout: Dictionary) -> void:
var sender := multiplayer.get_remote_sender_id() var sender := multiplayer.get_remote_sender_id()
if sender == peer_id or sender == 1 or sender == 0: if sender == peer_id or sender == 1 or sender == 0:
loadout = p_loadout.duplicate(true) loadout = p_loadout.duplicate(true)
if p_loadout.has("username") and p_loadout["username"] is String:
username = p_loadout["username"]
if p_loadout.has("user_id"):
user_id = int(p_loadout["user_id"])
+6
View File
@@ -94,6 +94,12 @@ func _on_back_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn") get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled()
_on_back_pressed()
func show_message(text: String, is_error: bool = false) -> void: func show_message(text: String, is_error: bool = false) -> void:
if status_label: if status_label:
status_label.text = text status_label.text = text
+7
View File
@@ -174,3 +174,10 @@ func _on_submit_pressed() -> void:
report_submitted.emit(target_type, target_id) report_submitted.emit(target_type, target_id)
await get_tree().create_timer(1.0).timeout await get_tree().create_timer(1.0).timeout
queue_free() queue_free()
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled()
closed.emit()
queue_free()
+11
View File
@@ -35,6 +35,10 @@ signal back_requested
var _api_client: Node = null var _api_client: Node = null
var AudioManager: Node:
get:
return get_node_or_null("/root/AudioManager")
func _ready() -> void: func _ready() -> void:
_api_client = _get_api() _api_client = _get_api()
@@ -76,6 +80,12 @@ func _on_back_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn") get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled()
_on_back_pressed()
func show_status(text: String, is_error: bool = false) -> void: func show_status(text: String, is_error: bool = false) -> void:
if not status_label: if not status_label:
return return
@@ -269,6 +279,7 @@ func _on_request_payout_pressed() -> void:
show_status("Submitting payout request for %d Buffalo Chips..." % amount) show_status("Submitting payout request for %d Buffalo Chips..." % amount)
var res: Dictionary = await EconomyApiClient.request_payout(api, amount, dest) var res: Dictionary = await EconomyApiClient.request_payout(api, amount, dest)
if res.ok: if res.ok:
AudioManager.play_coin()
show_status("Payout request for %d Buffalo Chips submitted successfully!" % amount) show_status("Payout request for %d Buffalo Chips submitted successfully!" % amount)
if payout_amount_input: payout_amount_input.text = "" if payout_amount_input: payout_amount_input.text = ""
if payout_dest_input: payout_dest_input.text = "" if payout_dest_input: payout_dest_input.text = ""
+125
View File
@@ -0,0 +1,125 @@
extends SceneTree
## Headless smoke test for AudioManager autoload.
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
print("SMOKE: Starting AudioManager verification...")
var audio_mgr := root.get_node_or_null("AudioManager")
if audio_mgr == null:
printerr("FAIL: AudioManager node not found in root")
quit(1)
return
print("PASS: AudioManager autoload exists in scene tree.")
# 1. Verify streams are valid AudioStream objects
var streams := [
["STREAM_CLICK", audio_mgr.STREAM_CLICK],
["STREAM_COIN", audio_mgr.STREAM_COIN],
["STREAM_CHIME", audio_mgr.STREAM_CHIME],
["STREAM_FANFARE", audio_mgr.STREAM_FANFARE],
["STREAM_BELL", audio_mgr.STREAM_BELL],
["STREAM_DOOR", audio_mgr.STREAM_DOOR],
]
for s in streams:
var name_str: String = s[0]
var stream_obj: Variant = s[1]
if not (stream_obj is AudioStream):
printerr("FAIL: %s is not an AudioStream" % name_str)
quit(1)
return
print("PASS: %s is a valid AudioStream." % name_str)
# 2. Verify pool size is 16
if audio_mgr.pool_size != 16:
printerr("FAIL: Expected pool size 16, got %d" % audio_mgr.pool_size)
quit(1)
return
print("PASS: Pool size is 16.")
# 3. Call sound helpers
var p1: AudioStreamPlayer = audio_mgr.play_click()
if p1 == null or not (p1 is AudioStreamPlayer):
printerr("FAIL: play_click() did not return an AudioStreamPlayer")
quit(1)
return
var p2: AudioStreamPlayer = audio_mgr.play_coin()
if p2 == null or not (p2 is AudioStreamPlayer):
printerr("FAIL: play_coin() did not return an AudioStreamPlayer")
quit(1)
return
var p3: AudioStreamPlayer = audio_mgr.play_chime()
if p3 == null or not (p3 is AudioStreamPlayer):
printerr("FAIL: play_chime() did not return an AudioStreamPlayer")
quit(1)
return
var p4: AudioStreamPlayer = audio_mgr.play_fanfare()
if p4 == null or not (p4 is AudioStreamPlayer):
printerr("FAIL: play_fanfare() did not return an AudioStreamPlayer")
quit(1)
return
var p5: AudioStreamPlayer = audio_mgr.play_bell()
if p5 == null or not (p5 is AudioStreamPlayer):
printerr("FAIL: play_bell() did not return an AudioStreamPlayer")
quit(1)
return
var p6: AudioStreamPlayer = audio_mgr.play_door()
if p6 == null or not (p6 is AudioStreamPlayer):
printerr("FAIL: play_door() did not return an AudioStreamPlayer")
quit(1)
return
print("PASS: All 6 sound helper methods executed and returned players.")
# 4. Fire 20 rapid sounds to verify round-robin recycling
var player_order: Array[AudioStreamPlayer] = []
for i in range(20):
var p: AudioStreamPlayer = audio_mgr.play_click()
if p == null:
printerr("FAIL: Rapid sound #%d returned null" % i)
quit(1)
return
player_order.append(p)
if player_order[16] != player_order[0]:
printerr("FAIL: Player at index 16 is not recycled player at index 0")
quit(1)
return
if player_order[17] != player_order[1]:
printerr("FAIL: Player at index 17 is not recycled player at index 1")
quit(1)
return
if player_order[18] != player_order[2]:
printerr("FAIL: Player at index 18 is not recycled player at index 2")
quit(1)
return
if player_order[19] != player_order[3]:
printerr("FAIL: Player at index 19 is not recycled player at index 3")
quit(1)
return
print("PASS: 20 rapid sounds verified round-robin recycling.")
# 5. Verify universal tactile button click hook
var btn := Button.new()
btn.text = "Test Button"
root.add_child(btn)
await process_frame
btn.emit_signal("pressed")
# Button in no_click_sound group
var silent_btn := Button.new()
silent_btn.add_to_group("no_click_sound")
root.add_child(silent_btn)
await process_frame
silent_btn.emit_signal("pressed")
print("SMOKE RESULT: PASS")
quit(0)
+298
View File
@@ -0,0 +1,298 @@
extends SceneTree
## Smoke test for redesigned AvatarCustomizationUI (Tasks 680b6e91d699 & f87183b6d9e4).
##
## Verifies:
## 1. Header Bar: Back button (< Back [ESC], Vector2(120, 38)), Title ("Avatar Studio"),
## Live Buffalo Chips balance label ("Wallet: %d Buffalo Chips"), Status pill ("All changes saved").
## 2. Category Tabs: Tab 0 "Body & Head", Tab 1 "Clothing", Tab 2 "Accessories", Tab 3 "Skin & Colors".
## 3. Human-friendly item names (display_name lookup, no raw IDs like hair_mohawk_01 as primary title).
## 4. Equipped badges ("✓ EQUIPPED") and highlighted borders on equipped items.
## 5. Skin & Colors presets, shading slider, and custom RGB sliders.
## 6. Preview Camera Zoom & Framing Presets:
## - Presets: "Full Body" (3.5, 0.98), "Head" (1.6, 1.62), "Torso" (2.2, 1.12), "Feet" (1.8, 0.35).
## - Zoom clamping: [1.5, 4.5].
## - Mouse-wheel zoom events.
## - Camera controls bar with preset buttons, zoom slider, and +/- buttons.
## 7. Auto-Save on Exit:
## - user://loadout.json written.
## - AvatarSystem sync.
## - Signals: back_requested and closed.
const AvatarCustomizationUI := preload("res://scripts/avatar/avatar_customization_ui.gd")
const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd")
var passed := 0
var failed := 0
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
print("SMOKE: Starting AvatarStudioUI verification...")
var ui: AvatarCustomizationUI = AvatarCustomizationUI.new()
ui.name = "AvatarCustomizationUI"
ui.set_anchors_preset(Control.PRESET_FULL_RECT)
root.add_child(ui)
await process_frame
await process_frame
# 1. Header Bar Verification
var back_btn: Button = ui.find_child("BackButton", true, false) as Button
_check("Back button exists", back_btn != null)
if back_btn:
_check("Back button text is '< Back [ESC]'", back_btn.text == "< Back [ESC]")
_check("Back button custom_minimum_size is at least (120, 38)",
back_btn.custom_minimum_size.x >= 120 and back_btn.custom_minimum_size.y >= 38)
var title_lbl: Label = ui.find_child("TitleLabel", true, false) as Label
_check("TitleLabel exists", title_lbl != null)
if title_lbl:
_check("TitleLabel text is 'Avatar Studio'", title_lbl.text == "Avatar Studio")
var fsize: int = title_lbl.get_theme_font_size("font_size")
_check("TitleLabel font_size >= 24", fsize >= 24)
var status_pill: Label = ui.find_child("StatusPill", true, false) as Label
_check("StatusPill exists", status_pill != null)
if status_pill:
_check("StatusPill text is 'All changes saved'", status_pill.text == "All changes saved")
var wallet_lbl: Label = ui.find_child("WalletBalanceLabel", true, false) as Label
_check("WalletBalanceLabel exists", wallet_lbl != null)
if wallet_lbl:
_check("WalletBalanceLabel starts with 'Wallet:' and contains 'Buffalo Chips'",
wallet_lbl.text.begins_with("Wallet:") and wallet_lbl.text.contains("Buffalo Chips"))
ui.update_wallet_balance(750)
_check("WalletBalanceLabel formats 'Wallet: 750 Buffalo Chips'",
wallet_lbl.text == "Wallet: 750 Buffalo Chips")
# 2. Category Tabs Verification
var tab_bar: TabBar = ui.find_child("CategoryTabBar", true, false) as TabBar
_check("CategoryTabBar exists", tab_bar != null)
if tab_bar:
_check("CategoryTabBar has 4 tabs", tab_bar.tab_count == 4)
_check("Tab 0 is 'Body & Head'", tab_bar.get_tab_title(0) == "Body & Head")
_check("Tab 1 is 'Clothing'", tab_bar.get_tab_title(1) == "Clothing")
_check("Tab 2 is 'Accessories'", tab_bar.get_tab_title(2) == "Accessories")
_check("Tab 3 is 'Skin & Colors'", tab_bar.get_tab_title(3) == "Skin & Colors")
# Check Category constants
_check("Category 0 has slots: base_body, head, hair, face",
ui.CATEGORIES[0]["slots"] == ["base_body", "head", "hair", "face"])
_check("Category 1 has slots: chest, pants, torso, left_foot, left_hand",
ui.CATEGORIES[1]["slots"] == ["chest", "pants", "torso", "left_foot", "left_hand"])
_check("Category 2 has slots: back, accessory",
ui.CATEGORIES[2]["slots"] == ["back", "accessory"])
# 3. Friendly Item Names Verification
var mohawk_name: String = ui.get_friendly_item_name("hair_mohawk_01")
_check("hair_mohawk_01 friendly name is 'Mohawk'", mohawk_name == "Mohawk")
var cyber_name: String = ui.get_friendly_item_name("head_cyber_01")
_check("head_cyber_01 friendly name is 'Cyber Head'", cyber_name == "Cyber Head")
var cargo_name: String = ui.get_friendly_item_name("pants_cargo_01")
_check("pants_cargo_01 friendly name is 'Cargo Pants'", cargo_name == "Cargo Pants")
var base_name: String = ui.get_friendly_item_name("base_human_male_01")
_check("base_human_male_01 friendly name is 'Human Male Base'", base_name == "Human Male Base")
var none_name: String = ui.get_friendly_item_name("")
_check("empty asset_id friendly name is '(None)'", none_name == "(None)")
# 4. Item Selection, Equipped Badges, and Highlighted Borders
ui._select_slot("hair")
await process_frame
var item_list: VBoxContainer = ui.find_child("ItemList", true, false) as VBoxContainer
_check("ItemList exists and has children", item_list != null and item_list.get_child_count() > 0)
var mohawk_btn: Button = ui.find_child("Item_hair_mohawk_01", true, false) as Button
_check("Item_hair_mohawk_01 button exists", mohawk_btn != null)
if mohawk_btn:
_check("Mohawk button primary text does not show raw ID 'hair_mohawk_01'",
not mohawk_btn.text.begins_with("hair_mohawk_01"))
_check("Mohawk button contains friendly name 'Mohawk'",
mohawk_btn.text.contains("Mohawk"))
# Equip mohawk
var tracker := {"loadout_changed": false, "back_requested": false, "closed": false}
ui.loadout_changed.connect(func(_ld): tracker["loadout_changed"] = true)
ui._on_equip_pressed("hair_mohawk_01")
await process_frame
_check("Equipping mohawk emitted loadout_changed signal", tracker["loadout_changed"])
_check("Loadout hair slot is 'hair_mohawk_01'",
ui.loadout.get_equipment("hair") == "hair_mohawk_01")
# Re-check button equipped badge & border
mohawk_btn = ui.find_child("Item_hair_mohawk_01", true, false) as Button
if mohawk_btn:
var badge: Label = mohawk_btn.find_child("EquippedBadge", true, false) as Label
_check("Equipped mohawk has '✓ EQUIPPED' badge",
badge != null or mohawk_btn.text.contains("✓ EQUIPPED"))
var sb: StyleBox = mohawk_btn.get_theme_stylebox("normal")
if sb is StyleBoxFlat:
_check("Equipped mohawk has highlighted border (width >= 2)",
(sb as StyleBoxFlat).border_width_left >= 2)
# Unequip hair
ui._on_equip_pressed("")
await process_frame
_check("Unequipping hair clears hair slot", ui.loadout.get_equipment("hair") == "")
var none_btn: Button = ui.find_child("Item_none", true, false) as Button
if none_btn:
var none_badge: Label = none_btn.find_child("EquippedBadge", true, false) as Label
_check("(None) button has '✓ EQUIPPED' badge when slot is unequipped",
none_badge != null or none_btn.text.contains("✓ EQUIPPED"))
# 5. Category Tab Switching
ui._on_category_tab_changed(1) # Clothing
await process_frame
var slot_list: HBoxContainer = ui.find_child("SlotList", true, false) as HBoxContainer
_check("Switching to Clothing tab populates slot buttons", slot_list != null and slot_list.get_child_count() >= 3)
var chest_slot_btn: Button = ui.find_child("Slot_chest", true, false) as Button
_check("Slot_chest button exists in Clothing tab", chest_slot_btn != null)
# 6. Skin & Colors Tab Verification
ui._on_category_tab_changed(3) # Skin & Colors
await process_frame
var items_section: Control = ui.find_child("ItemsSection", true, false) as Control
var skin_section: Control = ui.find_child("SkinSection", true, false) as Control
_check("Skin & Colors tab hides ItemsSection and shows SkinSection",
items_section != null and not items_section.visible and skin_section != null and skin_section.visible)
var shade_slider: HSlider = ui.find_child("ShadeSlider", true, false) as HSlider
_check("ShadeSlider exists", shade_slider != null)
var slider_r: HSlider = ui.find_child("SliderR", true, false) as HSlider
var slider_g: HSlider = ui.find_child("SliderG", true, false) as HSlider
var slider_b: HSlider = ui.find_child("SliderB", true, false) as HSlider
_check("RGB sliders (R, G, B) exist", slider_r != null and slider_g != null and slider_b != null)
var skin_preview: ColorRect = ui.find_child("SkinColorPreview", true, false) as ColorRect
_check("SkinColorPreview exists", skin_preview != null)
# Test swatch press
var test_color := Color(0.33, 0.21, 0.15)
ui._on_skin_swatch_pressed(test_color)
await process_frame
_check("Pressing swatch updates loadout skin_tone",
ui.loadout.skin_tone.is_equal_approx(test_color))
if skin_preview:
_check("SkinColorPreview matches swatch color",
skin_preview.color.is_equal_approx(test_color))
# Test RGB slider change
if slider_r:
slider_r.value = 0.75
ui._on_rgb_slider_changed(0.75)
await process_frame
_check("Adjusting RGB slider updates loadout skin_tone.r",
is_equal_approx(ui.loadout.skin_tone.r, 0.75))
_check("Status pill remains 'All changes saved'",
status_pill != null and status_pill.text == "All changes saved")
# 7. Camera Zoom & Framing Presets Verification (Task f87183b6d9e4)
# Check framing presets
ui.frame_camera_preset("Head")
_check("Head preset frames camera to dist 1.6, height 1.62",
is_equal_approx(ui.camera_distance, 1.6) and is_equal_approx(ui.camera_target_height, 1.62))
ui.frame_camera_preset("Torso")
_check("Torso preset frames camera to dist 2.2, height 1.12",
is_equal_approx(ui.camera_distance, 2.2) and is_equal_approx(ui.camera_target_height, 1.12))
ui.frame_camera_preset("Feet")
_check("Feet preset frames camera to dist 1.8, height 0.35",
is_equal_approx(ui.camera_distance, 1.8) and is_equal_approx(ui.camera_target_height, 0.35))
ui.frame_camera_preset("Full Body")
_check("Full Body preset frames camera to dist 3.5, height 0.98",
is_equal_approx(ui.camera_distance, 3.5) and is_equal_approx(ui.camera_target_height, 0.98))
# Check zoom clamping [1.5, 4.5]
ui.set_camera_zoom(1.0) # should clamp to 1.5
_check("set_camera_zoom(1.0) clamps to 1.5", is_equal_approx(ui.camera_distance, 1.5))
ui.set_camera_zoom(5.0) # should clamp to 4.5
_check("set_camera_zoom(5.0) clamps to 4.5", is_equal_approx(ui.camera_distance, 4.5))
# Check mouse wheel zoom on preview container
ui.set_camera_zoom(3.0)
var wheel_up := InputEventMouseButton.new()
wheel_up.button_index = MOUSE_BUTTON_WHEEL_UP
wheel_up.pressed = true
ui._on_preview_gui_input(wheel_up)
_check("Wheel up zooms in by 0.2m (3.0 -> 2.8)", is_equal_approx(ui.camera_distance, 2.8))
var wheel_down := InputEventMouseButton.new()
wheel_down.button_index = MOUSE_BUTTON_WHEEL_DOWN
wheel_down.pressed = true
ui._on_preview_gui_input(wheel_down)
_check("Wheel down zooms out by 0.2m (2.8 -> 3.0)", is_equal_approx(ui.camera_distance, 3.0))
# Check Camera Controls Bar UI elements
var p_head_btn: Button = ui.find_child("Preset_Head", true, false) as Button
var p_full_btn: Button = ui.find_child("Preset_FullBody", true, false) as Button
var p_torso_btn: Button = ui.find_child("Preset_Torso", true, false) as Button
var p_feet_btn: Button = ui.find_child("Preset_Feet", true, false) as Button
_check("Preset buttons (Full Body, Head, Torso, Feet) exist",
p_head_btn != null and p_full_btn != null and p_torso_btn != null and p_feet_btn != null)
var zoom_slider: HSlider = ui.find_child("CameraZoomSlider", true, false) as HSlider
var zoom_in: Button = ui.find_child("ZoomInBtn", true, false) as Button
var zoom_out: Button = ui.find_child("ZoomOutBtn", true, false) as Button
_check("Zoom slider and +/- buttons exist",
zoom_slider != null and zoom_in != null and zoom_out != null)
# 8. Auto-Save on Exit Verification (Task f87183b6d9e4)
ui.back_requested.connect(func(): tracker["back_requested"] = true)
ui.closed.connect(func(): tracker["closed"] = true)
# Equip a distinct item before exit
ui._select_slot("head")
ui._on_equip_pressed("head_cyber_01")
await process_frame
# Trigger save_and_exit
await ui.save_and_exit()
await process_frame
_check("save_and_exit emits back_requested signal", tracker["back_requested"])
_check("save_and_exit emits closed signal", tracker["closed"])
# Check that user://loadout.json exists and contains head_cyber_01
_check("user://loadout.json was saved to disk", FileAccess.file_exists("user://loadout.json"))
if FileAccess.file_exists("user://loadout.json"):
var f := FileAccess.open("user://loadout.json", FileAccess.READ)
var json_str := f.get_as_text()
f.close()
_check("user://loadout.json contains 'head_cyber_01'", json_str.contains("head_cyber_01"))
# Check AvatarSystem sync
var avatars := root.get_node_or_null("AvatarSystem")
if avatars != null and avatars.current_loadout != null:
_check("AvatarSystem current_loadout synced 'head_cyber_01'",
avatars.current_loadout.get_equipment("head") == "head_cyber_01")
ui.queue_free()
_finish()
func _check(description: String, condition: bool) -> bool:
if condition:
print(" PASS: ", description)
passed += 1
else:
printerr(" FAIL: ", description)
failed += 1
return condition
func _finish() -> void:
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
quit(0 if failed == 0 else 1)
+23 -4
View File
@@ -81,6 +81,13 @@ func _run() -> void:
return return
enter_button.pressed.emit() enter_button.pressed.emit()
await process_frame
var briefing: Node = instance.find_child("WorldBriefingModal", true, false)
if briefing != null:
var modal_enter := _find_button(briefing, "Enter World")
if modal_enter != null:
modal_enter.pressed.emit()
await _wait_for_scene_change(instance, 600) # generous -- a real POST /join may spawn a dedicated server (slow) await _wait_for_scene_change(instance, 600) # generous -- a real POST /join may spawn a dedicated server (slow)
var game_scene := current_scene var game_scene := current_scene
@@ -100,16 +107,28 @@ func _run() -> void:
var dist := (player as CharacterBody3D).global_position.distance_to((spawn_marker as Marker3D).global_position) var dist := (player as CharacterBody3D).global_position.distance_to((spawn_marker as Marker3D).global_position)
_check("player spawned at (or very near) the PlayerSpawn marker", dist < 0.5) _check("player spawned at (or very near) the PlayerSpawn marker", dist < 0.5)
# 5. Escape should return to world_list.tscn (real key event, not a # 5. Escape opens the Pause Menu, and Confirm Leave safely returns to world_list.tscn
# direct method call -- proves the actual input binding works).
var esc_event := InputEventKey.new() var esc_event := InputEventKey.new()
esc_event.keycode = KEY_ESCAPE esc_event.keycode = KEY_ESCAPE
esc_event.pressed = true esc_event.pressed = true
Input.parse_input_event(esc_event) Input.parse_input_event(esc_event)
await _wait_for_scene_change(game_scene, 60) await process_frame
await process_frame
var pause_menu: Node = game_scene.find_child("PauseMenu", true, false)
_check("Escape opened PauseMenu in-game", pause_menu != null)
if pause_menu != null:
var leave_btn := _find_button(pause_menu, "🚪 Leave World")
if leave_btn != null:
leave_btn.pressed.emit()
await process_frame
var confirm_btn := _find_button(pause_menu, "Confirm Leave")
if confirm_btn != null:
confirm_btn.pressed.emit()
await _wait_for_scene_change(game_scene, 120)
var back_to_menu := current_scene var back_to_menu := current_scene
_check("Escape returned to world_list.tscn", back_to_menu != null and back_to_menu.scene_file_path == "res://scenes/world_list.tscn") _check("Confirmed Leave returned to world_list.tscn", back_to_menu != null and back_to_menu.scene_file_path == "res://scenes/world_list.tscn")
_finish() _finish()
+12 -2
View File
@@ -11,8 +11,18 @@ static func get_api_client(node: Node) -> Node:
# ── Friends & Follow & Block ── # ── Friends & Follow & Block ──
static func send_friend_request(api: Node, user_id: int) -> Dictionary: static func send_friend_request(api: Node, target: Variant) -> Dictionary:
return await api.request(HTTPClient.METHOD_POST, "/api/friends/requests", JSON.stringify({"user_id": user_id}), "application/json", true) var payload: Dictionary = {}
if target is int:
payload = {"user_id": target}
elif target is String:
if (target as String).is_valid_int():
payload = {"user_id": int(target)}
else:
payload = {"username": (target as String).strip_edges()}
else:
payload = {"user_id": int(target)}
return await api.request(HTTPClient.METHOD_POST, "/api/friends/requests", JSON.stringify(payload), "application/json", true)
static func get_friend_requests(api: Node) -> Dictionary: static func get_friend_requests(api: Node) -> Dictionary:
return await api.request(HTTPClient.METHOD_GET, "/api/friends/requests", "", "application/json", true) return await api.request(HTTPClient.METHOD_GET, "/api/friends/requests", "", "application/json", true)
+10
View File
@@ -27,6 +27,9 @@ signal back_requested
@onready var page_label: Label = $VBox/LedgerSection/PagingHBox/PageLabel @onready var page_label: Label = $VBox/LedgerSection/PagingHBox/PageLabel
var _api_client: Node = null var _api_client: Node = null
var AudioManager: Node:
get:
return get_node_or_null("/root/AudioManager")
var current_page: int = 0 var current_page: int = 0
const PAGE_SIZE: int = 20 const PAGE_SIZE: int = 20
var total_tx_count: int = 0 var total_tx_count: int = 0
@@ -72,6 +75,12 @@ func _on_back_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn") get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled()
_on_back_pressed()
func show_status(text: String, is_error: bool = false) -> void: func show_status(text: String, is_error: bool = false) -> void:
if not status_label: if not status_label:
return return
@@ -123,6 +132,7 @@ func _buy_pack(credits: int) -> void:
show_status("Processing purchase of %d Buffalo Chips..." % credits) show_status("Processing purchase of %d Buffalo Chips..." % credits)
var res: Dictionary = await EconomyApiClient.purchase_credits(api, credits) var res: Dictionary = await EconomyApiClient.purchase_credits(api, credits)
if res.ok and res.data is Dictionary: if res.ok and res.data is Dictionary:
AudioManager.play_coin()
var new_bal: int = int(res.data.get("new_balance", 0)) var new_bal: int = int(res.data.get("new_balance", 0))
if balance_label: if balance_label:
balance_label.text = "%d Buffalo Chips" % new_bal balance_label.text = "%d Buffalo Chips" % new_bal
+6
View File
@@ -222,3 +222,9 @@ func _on_report_world_pressed() -> void:
var rep_modal = _report_modal_scene.new() var rep_modal = _report_modal_scene.new()
rep_modal.setup_report("world", world_id, world_name) rep_modal.setup_report("world", world_id, world_name)
get_parent().add_child(rep_modal) get_parent().add_child(rep_modal)
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled()
_on_cancel_pressed()
+16
View File
@@ -438,3 +438,19 @@ func _on_enter_world_pressed(world_id: int, button: Button) -> void:
func _on_edit_world_pressed(world_id: int) -> void: func _on_edit_world_pressed(world_id: int) -> void:
Session.begin_edit(world_id) Session.begin_edit(world_id)
get_tree().change_scene_to_file("res://scenes/game.tscn") get_tree().change_scene_to_file("res://scenes/game.tscn")
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
if _create_modal != null and is_instance_valid(_create_modal):
_create_modal.queue_free()
_create_modal = null
get_viewport().set_input_as_handled()
return
for child in get_children():
if child is WorldBriefingModal:
child.queue_free()
get_viewport().set_input_as_handled()
return
get_viewport().set_input_as_handled()
_on_back_button_pressed()