feat(mvp): complete M13 clean-up milestone (real usernames, avatar studio redesign, pause menu, audio manager)
checkpoint / checkpoint (push) Successful in 6m22s
checkpoint / checkpoint (push) Successful in 6m22s
This commit is contained in:
@@ -46,7 +46,8 @@ DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
|
||||
class FriendRequestCreate(BaseModel):
|
||||
user_id: int
|
||||
user_id: int | None = None
|
||||
username: str | None = None
|
||||
|
||||
|
||||
class FriendRequestResponse(BaseModel):
|
||||
@@ -55,6 +56,7 @@ class FriendRequestResponse(BaseModel):
|
||||
addressee_id: int
|
||||
status: FriendshipStatus
|
||||
created_at: datetime
|
||||
requester_username: str | None = None
|
||||
|
||||
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)
|
||||
async def send_friend_request(body: FriendRequestCreate, current_user: CurrentUser, db: DbDep):
|
||||
"""Send a friend request to another user."""
|
||||
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:
|
||||
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()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Friend request or friendship already exists")
|
||||
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)
|
||||
async def list_incoming_requests(current_user: CurrentUser, db: DbDep):
|
||||
"""List pending friend requests sent *to* the authenticated user."""
|
||||
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.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)
|
||||
|
||||
@@ -25,6 +25,13 @@ class UserResponse(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserSummary(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: EmailStr | None = None
|
||||
is_active: bool | None = None
|
||||
@@ -40,6 +47,43 @@ async def get_current_user_profile(
|
||||
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)
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
|
||||
@@ -127,6 +127,26 @@ class TestFriendRequestFlow:
|
||||
again = client.delete(f"/api/friends/{a['id']}", headers=b["headers"])
|
||||
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:
|
||||
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/blocked").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
|
||||
|
||||
@@ -14,6 +14,7 @@ agp/api_base_url="http://127.0.0.1:8000"
|
||||
[autoload]
|
||||
|
||||
ApiClient="*res://scripts/api_client.gd"
|
||||
AudioManager="*res://scripts/audio_manager.gd"
|
||||
AvatarSystem="*res://scripts/avatar/avatar_system.gd"
|
||||
Session="*res://scripts/session.gd"
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ theme_override_constants/separation = 8
|
||||
[node name="TargetUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Requests/SendHBox"]
|
||||
layout_mode = 2
|
||||
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"]
|
||||
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"]
|
||||
layout_mode = 2
|
||||
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"]
|
||||
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"]
|
||||
layout_mode = 2
|
||||
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"]
|
||||
custom_minimum_size = Vector2(100, 32)
|
||||
|
||||
+64
-10
@@ -39,6 +39,10 @@ extends Node
|
||||
var _access_token: String = ""
|
||||
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.
|
||||
var bearer_token: String:
|
||||
get:
|
||||
@@ -61,6 +65,8 @@ func set_token(access_token: String, token_type: String = "Bearer") -> void:
|
||||
func clear_token() -> void:
|
||||
_access_token = ""
|
||||
_token_type = "Bearer"
|
||||
current_user_id = 0
|
||||
current_username = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -209,6 +215,12 @@ func login(username: String, password: String) -> Dictionary:
|
||||
false
|
||||
)
|
||||
_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
|
||||
|
||||
|
||||
@@ -246,6 +258,12 @@ func register(
|
||||
false
|
||||
)
|
||||
_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
|
||||
|
||||
|
||||
@@ -282,7 +300,23 @@ func health_check() -> Dictionary:
|
||||
|
||||
## Fetch the current user's profile from the stored JWT (requires auth).
|
||||
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).
|
||||
@@ -312,9 +346,19 @@ func save_avatar_loadout(loadout: Dictionary) -> Dictionary:
|
||||
# Social Features: Friends, Follow & Block
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Send a friend request to target user_id.
|
||||
func send_friend_request(user_id: int) -> Dictionary:
|
||||
return await SocialApiClient.send_friend_request(self, user_id)
|
||||
## Send a friend request to target user_id (int) or username (String).
|
||||
func send_friend_request(target: Variant) -> Dictionary:
|
||||
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.
|
||||
func get_friend_requests() -> Dictionary:
|
||||
@@ -336,9 +380,14 @@ func unfriend(user_id: int) -> Dictionary:
|
||||
func get_friends() -> Dictionary:
|
||||
return await SocialApiClient.get_friends(self)
|
||||
|
||||
## Follow a user.
|
||||
func follow_user(user_id: int) -> Dictionary:
|
||||
return await SocialApiClient.follow_user(self, user_id)
|
||||
## Follow a user by ID or username.
|
||||
func follow_user(target: Variant) -> Dictionary:
|
||||
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.
|
||||
func unfollow_user(user_id: int) -> Dictionary:
|
||||
@@ -348,9 +397,14 @@ func unfollow_user(user_id: int) -> Dictionary:
|
||||
func get_following() -> Dictionary:
|
||||
return await SocialApiClient.get_following(self)
|
||||
|
||||
## Block a user.
|
||||
func block_user(user_id: int) -> Dictionary:
|
||||
return await SocialApiClient.block_user(self, user_id)
|
||||
## Block a user by ID or username.
|
||||
func block_user(target: Variant) -> Dictionary:
|
||||
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.
|
||||
func unblock_user(user_id: int) -> Dictionary:
|
||||
|
||||
@@ -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
@@ -2,8 +2,9 @@ extends Control
|
||||
## Full-screen host for the AvatarCustomizationUI.
|
||||
##
|
||||
## The UI itself (scripts/avatar/avatar_customization_ui.gd) builds its own
|
||||
## layout programmatically and emits `closed` when the player backs out; this
|
||||
## thin wrapper is what makes it a navigable scene reachable from the main menu.
|
||||
## layout programmatically, auto-saves on exit, and emits `closed` / `back_requested`
|
||||
## 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")
|
||||
|
||||
@@ -18,3 +19,13 @@ func _ready() -> void:
|
||||
|
||||
func _on_closed() -> void:
|
||||
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()
|
||||
|
||||
@@ -99,6 +99,12 @@ func _on_back_pressed() -> void:
|
||||
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:
|
||||
if status_label:
|
||||
status_label.text = text
|
||||
|
||||
@@ -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()
|
||||
@@ -399,3 +399,14 @@ func _on_back_pressed() -> void:
|
||||
func _on_world_list_pressed() -> void:
|
||||
if get_tree():
|
||||
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()
|
||||
|
||||
@@ -24,6 +24,7 @@ extends Control
|
||||
## UI and the in-memory `ChunkEditorModel` is unaffected either way.
|
||||
|
||||
signal closed
|
||||
signal exit_browser_requested
|
||||
|
||||
var core: EditorCore
|
||||
|
||||
@@ -32,12 +33,17 @@ var _palette: PalettePanel
|
||||
var _inspector: InspectorPanel
|
||||
var _viewport: EditorViewport
|
||||
var _close_button: Button
|
||||
var _exit_browser_button: Button
|
||||
var _save_button: Button
|
||||
var _save_as_button: Button
|
||||
var _save_status_label: Label
|
||||
var _instruction_label: Label
|
||||
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
|
||||
## `setup_persistence()`. `_save_service == null` means there is nothing to
|
||||
@@ -118,11 +124,18 @@ func _build_ui() -> void:
|
||||
|
||||
|
||||
_close_button = Button.new()
|
||||
_close_button.text = "Close (F2 / Esc)"
|
||||
_close_button.tooltip_text = "Exit build mode and return to game"
|
||||
_close_button.text = "▶ Playtest (F2)"
|
||||
_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)
|
||||
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.add_theme_font_size_override("font_size", 13)
|
||||
_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")
|
||||
_toolbar.set_active_tool("erase")
|
||||
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:
|
||||
@@ -311,6 +330,7 @@ func _on_save_pressed() -> void:
|
||||
_save_button.disabled = false
|
||||
|
||||
if result.get("ok", false):
|
||||
AudioManager.play_chime()
|
||||
_base_revision = int(result.get("revision", _base_revision))
|
||||
var msg := "Saved (revision %d)." % _base_revision
|
||||
if bool(result.get("retried", false)):
|
||||
@@ -506,3 +526,10 @@ func _api_client() -> Node:
|
||||
func _session() -> Node:
|
||||
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")
|
||||
|
||||
@@ -41,6 +41,12 @@ var _friend_presence_map: Dictionary = {} # user_id (int) -> {"status": String,
|
||||
|
||||
|
||||
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()
|
||||
_wire_signals()
|
||||
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")
|
||||
|
||||
|
||||
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:
|
||||
if status_label:
|
||||
status_label.text = text
|
||||
@@ -254,7 +266,10 @@ func refresh_requests() -> void:
|
||||
hbox.add_theme_constant_override("separation", 12)
|
||||
|
||||
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
|
||||
hbox.add_child(lbl)
|
||||
|
||||
@@ -292,13 +307,12 @@ func _on_decline_request(req_id: int) -> void:
|
||||
|
||||
func _on_send_req_pressed() -> void:
|
||||
var target_text := send_req_input.text.strip_edges()
|
||||
if not target_text.is_valid_int():
|
||||
show_message("Enter a valid integer user ID.", true)
|
||||
if target_text.is_empty():
|
||||
show_message("Enter a username or user ID.", true)
|
||||
return
|
||||
var target_id := int(target_text)
|
||||
var res: Dictionary = await _api_client.send_friend_request(target_id)
|
||||
var res: Dictionary = await _api_client.send_friend_request(target_text)
|
||||
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 = ""
|
||||
else:
|
||||
show_message("Failed to send request: " + res.detail, true)
|
||||
@@ -357,13 +371,12 @@ func _on_unfollow(uid: int) -> void:
|
||||
|
||||
func _on_follow_pressed() -> void:
|
||||
var target_text := follow_input.text.strip_edges()
|
||||
if not target_text.is_valid_int():
|
||||
show_message("Enter a valid integer user ID.", true)
|
||||
if target_text.is_empty():
|
||||
show_message("Enter a username or user ID.", true)
|
||||
return
|
||||
var target_id := int(target_text)
|
||||
var res: Dictionary = await _api_client.follow_user(target_id)
|
||||
var res: Dictionary = await _api_client.follow_user(target_text)
|
||||
if res.ok:
|
||||
show_message("Following User #%d." % target_id)
|
||||
show_message("Following %s." % target_text)
|
||||
follow_input.text = ""
|
||||
refresh_following()
|
||||
else:
|
||||
@@ -423,13 +436,12 @@ func _on_unblock(uid: int) -> void:
|
||||
|
||||
func _on_block_pressed() -> void:
|
||||
var target_text := block_input.text.strip_edges()
|
||||
if not target_text.is_valid_int():
|
||||
show_message("Enter a valid integer user ID.", true)
|
||||
if target_text.is_empty():
|
||||
show_message("Enter a username or user ID.", true)
|
||||
return
|
||||
var target_id := int(target_text)
|
||||
var res: Dictionary = await _api_client.block_user(target_id)
|
||||
var res: Dictionary = await _api_client.block_user(target_text)
|
||||
if res.ok:
|
||||
show_message("Blocked User #%d." % target_id)
|
||||
show_message("Blocked %s." % target_text)
|
||||
block_input.text = ""
|
||||
refresh_blocked()
|
||||
refresh_friends()
|
||||
|
||||
+172
-22
@@ -36,11 +36,15 @@ var _status_label: Label = null
|
||||
## normal play"). Editing is in-memory only here; the save path to the
|
||||
## backend is a sibling task (world_persistence_impl.md).
|
||||
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 _chat_overlay: Control = null
|
||||
var _friends_overlay: Control = null
|
||||
var _report_modal: Control = null
|
||||
var _pause_menu: Control = null
|
||||
var _controls_modal: Control = null
|
||||
var _hud_layer: CanvasLayer = null
|
||||
var _overlay_layer: CanvasLayer = null
|
||||
|
||||
@@ -51,13 +55,13 @@ var _loaded_chunk_coords: Array[Vector3i] = []
|
||||
|
||||
func _ready() -> void:
|
||||
_show_build_stamp()
|
||||
_setup_in_game_hud()
|
||||
_setup_chunk_streaming()
|
||||
if Session.editing:
|
||||
_open_editor_for_world()
|
||||
else:
|
||||
_setup_in_game_hud()
|
||||
if Session.active:
|
||||
_setup_networked()
|
||||
_setup_chunk_streaming()
|
||||
|
||||
|
||||
func _setup_chunk_streaming() -> void:
|
||||
@@ -175,26 +179,22 @@ func _hide_default_test_props() -> void:
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
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 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()
|
||||
_toggle_chat_overlay()
|
||||
return
|
||||
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()
|
||||
_toggle_friends_overlay()
|
||||
return
|
||||
|
||||
if event.is_action_pressed("toggle_build_mode"):
|
||||
# world_persistence_impl.md §6 D4: the non-joined "Edit world" entry
|
||||
# already has its editor open from _ready() -- F2 here is the
|
||||
# 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
|
||||
|
||||
# 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"):
|
||||
get_viewport().set_input_as_handled()
|
||||
if _report_modal != null and is_instance_valid(_report_modal):
|
||||
@@ -203,21 +203,19 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
_close_chat_overlay()
|
||||
elif _friends_overlay != null and is_instance_valid(_friends_overlay):
|
||||
_close_friends_overlay()
|
||||
elif Session.editing:
|
||||
# Non-joined editing has no play session to fall back into --
|
||||
# closing it means leaving, same as the Close button.
|
||||
_exit_editor_to_browser()
|
||||
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):
|
||||
_close_build_mode()
|
||||
else:
|
||||
_leave_world()
|
||||
_open_pause_menu()
|
||||
|
||||
|
||||
# ─── In-Game HUD & Social Overlays ───────────────────────────────────────────
|
||||
|
||||
func _setup_in_game_hud() -> void:
|
||||
if Session.editing:
|
||||
return
|
||||
if _hud_layer != null and is_instance_valid(_hud_layer):
|
||||
return
|
||||
_hud_layer = CanvasLayer.new()
|
||||
@@ -274,13 +272,69 @@ func _setup_in_game_hud() -> void:
|
||||
_close_chat_overlay()
|
||||
elif _friends_overlay != null and is_instance_valid(_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):
|
||||
_close_build_mode()
|
||||
else:
|
||||
_leave_world()
|
||||
_open_pause_menu()
|
||||
)
|
||||
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:
|
||||
if _report_modal != null and is_instance_valid(_report_modal):
|
||||
@@ -451,6 +505,90 @@ func _close_friends_overlay() -> void:
|
||||
_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).
|
||||
## 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
|
||||
@@ -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.closed.connect(_close_build_mode)
|
||||
_build_editor.exit_browser_requested.connect(_exit_editor_to_browser)
|
||||
add_child(_build_editor)
|
||||
|
||||
|
||||
@@ -578,7 +717,8 @@ func _open_editor_for_world() -> void:
|
||||
|
||||
_build_editor = InGameEditor.new(core)
|
||||
_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)
|
||||
|
||||
|
||||
@@ -728,13 +868,23 @@ func _sync_remotes(states: Dictionary, delta: float) -> void:
|
||||
var st: Dictionary = states[pid]
|
||||
var node: Node3D = _remotes.get(pid)
|
||||
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:
|
||||
node = _make_remote(pid, loadout)
|
||||
node = _make_remote(pid, loadout, uname)
|
||||
_remotes[pid] = node
|
||||
add_child(node)
|
||||
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:
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
func _make_remote(pid: int, loadout: Variant = null) -> Node3D:
|
||||
func _make_remote(pid: int, loadout: Variant = null, uname: String = "") -> Node3D:
|
||||
var root := Node3D.new()
|
||||
root.name = "Remote_%d" % pid
|
||||
|
||||
@@ -764,7 +914,7 @@ func _make_remote(pid: int, loadout: Variant = null) -> Node3D:
|
||||
|
||||
var tag := Label3D.new()
|
||||
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.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
tag.no_depth_test = true
|
||||
|
||||
@@ -89,6 +89,12 @@ func _on_back_pressed() -> void:
|
||||
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:
|
||||
if status_label:
|
||||
status_label.text = text
|
||||
|
||||
+54
-1
@@ -52,6 +52,7 @@ var _quit_button: Button
|
||||
|
||||
var _is_register_mode: bool = false
|
||||
var _is_authenticating: bool = false
|
||||
var _sign_out_button: Button = null
|
||||
|
||||
|
||||
# ─── Lifecycle ────────────────────────────────────────────────────────────────
|
||||
@@ -112,6 +113,18 @@ func _ready() -> void:
|
||||
)
|
||||
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.
|
||||
_auth_buttons_row = $CenterContainer/VBoxContainer/AuthButtonsRow
|
||||
_login_button = $CenterContainer/VBoxContainer/AuthButtonsRow/LoginButton
|
||||
@@ -121,6 +134,8 @@ func _ready() -> void:
|
||||
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
|
||||
_password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit
|
||||
_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
|
||||
_auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton
|
||||
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
|
||||
@@ -198,6 +213,15 @@ func _show_authenticated(username: String) -> void:
|
||||
_auth_mode_label.visible = false
|
||||
_set_nav_enabled(true)
|
||||
_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)
|
||||
|
||||
|
||||
@@ -207,6 +231,8 @@ func _reset_to_logged_out() -> void:
|
||||
_auth_panel.visible = false
|
||||
_status_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
|
||||
_over_18_check.visible = false
|
||||
_over_18_check.button_pressed = true
|
||||
@@ -214,6 +240,13 @@ func _reset_to_logged_out() -> void:
|
||||
_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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
func _on_discover_button_pressed() -> void:
|
||||
@@ -313,7 +346,7 @@ func _update_auth_mode_ui() -> void:
|
||||
|
||||
# ─── Auth submission ──────────────────────────────────────────────────────────
|
||||
|
||||
func _on_auth_button_pressed() -> void:
|
||||
func _on_auth_button_pressed(_text: String = "") -> void:
|
||||
"""Handle login or register button press."""
|
||||
if _is_authenticating:
|
||||
return
|
||||
@@ -428,3 +461,23 @@ func _show_status(message: String) -> void:
|
||||
func _clear_status() -> void:
|
||||
_status_label.text = ""
|
||||
_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)
|
||||
|
||||
@@ -30,6 +30,10 @@ signal item_purchased(listing_id: int)
|
||||
|
||||
var _api_client: Node = null
|
||||
|
||||
var AudioManager: Node:
|
||||
get:
|
||||
return get_node_or_null("/root/AudioManager")
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_api_client = _get_api()
|
||||
@@ -73,6 +77,12 @@ func _on_back_pressed() -> void:
|
||||
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:
|
||||
if not status_label:
|
||||
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])
|
||||
var res: Dictionary = await EconomyApiClient.purchase_listing(api, listing_id)
|
||||
if res.ok:
|
||||
AudioManager.play_coin()
|
||||
show_status("Successfully purchased '%s'! Item added to inventory." % item_name)
|
||||
item_purchased.emit(listing_id)
|
||||
refresh_all()
|
||||
|
||||
@@ -101,6 +101,19 @@ func _on_connected() -> void:
|
||||
_server_root.add_child(_player_proxy)
|
||||
|
||||
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():
|
||||
_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
|
||||
## to other clients.
|
||||
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):
|
||||
_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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -25,6 +25,9 @@ var last_applied_seq: int = 0
|
||||
## Replicated avatar loadout (base_body, slots, skin_tone).
|
||||
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}
|
||||
## appended by rpc_request_input, drained each physics frame.
|
||||
var _input_queue: Array[Dictionary] = []
|
||||
@@ -80,6 +83,8 @@ func get_server_state() -> Dictionary:
|
||||
"velocity": velocity,
|
||||
"on_floor": is_on_floor(),
|
||||
"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()
|
||||
if sender == peer_id or sender == 1 or sender == 0:
|
||||
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"])
|
||||
|
||||
@@ -94,6 +94,12 @@ func _on_back_pressed() -> void:
|
||||
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:
|
||||
if status_label:
|
||||
status_label.text = text
|
||||
|
||||
@@ -174,3 +174,10 @@ func _on_submit_pressed() -> void:
|
||||
report_submitted.emit(target_type, target_id)
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
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()
|
||||
|
||||
@@ -35,6 +35,10 @@ signal back_requested
|
||||
|
||||
var _api_client: Node = null
|
||||
|
||||
var AudioManager: Node:
|
||||
get:
|
||||
return get_node_or_null("/root/AudioManager")
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_api_client = _get_api()
|
||||
@@ -76,6 +80,12 @@ func _on_back_pressed() -> void:
|
||||
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:
|
||||
if not status_label:
|
||||
return
|
||||
@@ -269,6 +279,7 @@ func _on_request_payout_pressed() -> void:
|
||||
show_status("Submitting payout request for %d Buffalo Chips..." % amount)
|
||||
var res: Dictionary = await EconomyApiClient.request_payout(api, amount, dest)
|
||||
if res.ok:
|
||||
AudioManager.play_coin()
|
||||
show_status("Payout request for %d Buffalo Chips submitted successfully!" % amount)
|
||||
if payout_amount_input: payout_amount_input.text = ""
|
||||
if payout_dest_input: payout_dest_input.text = ""
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -81,6 +81,13 @@ func _run() -> void:
|
||||
return
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
_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
|
||||
# direct method call -- proves the actual input binding works).
|
||||
# 5. Escape opens the Pause Menu, and Confirm Leave safely returns to world_list.tscn
|
||||
var esc_event := InputEventKey.new()
|
||||
esc_event.keycode = KEY_ESCAPE
|
||||
esc_event.pressed = true
|
||||
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
|
||||
_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()
|
||||
|
||||
|
||||
@@ -11,8 +11,18 @@ static func get_api_client(node: Node) -> Node:
|
||||
|
||||
# ── Friends & Follow & Block ──
|
||||
|
||||
static func send_friend_request(api: Node, user_id: int) -> Dictionary:
|
||||
return await api.request(HTTPClient.METHOD_POST, "/api/friends/requests", JSON.stringify({"user_id": user_id}), "application/json", true)
|
||||
static func send_friend_request(api: Node, target: Variant) -> Dictionary:
|
||||
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:
|
||||
return await api.request(HTTPClient.METHOD_GET, "/api/friends/requests", "", "application/json", true)
|
||||
|
||||
@@ -27,6 +27,9 @@ signal back_requested
|
||||
@onready var page_label: Label = $VBox/LedgerSection/PagingHBox/PageLabel
|
||||
|
||||
var _api_client: Node = null
|
||||
var AudioManager: Node:
|
||||
get:
|
||||
return get_node_or_null("/root/AudioManager")
|
||||
var current_page: int = 0
|
||||
const PAGE_SIZE: int = 20
|
||||
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")
|
||||
|
||||
|
||||
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:
|
||||
if not status_label:
|
||||
return
|
||||
@@ -123,6 +132,7 @@ func _buy_pack(credits: int) -> void:
|
||||
show_status("Processing purchase of %d Buffalo Chips..." % credits)
|
||||
var res: Dictionary = await EconomyApiClient.purchase_credits(api, credits)
|
||||
if res.ok and res.data is Dictionary:
|
||||
AudioManager.play_coin()
|
||||
var new_bal: int = int(res.data.get("new_balance", 0))
|
||||
if balance_label:
|
||||
balance_label.text = "%d Buffalo Chips" % new_bal
|
||||
|
||||
@@ -222,3 +222,9 @@ func _on_report_world_pressed() -> void:
|
||||
var rep_modal = _report_modal_scene.new()
|
||||
rep_modal.setup_report("world", world_id, world_name)
|
||||
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()
|
||||
|
||||
@@ -438,3 +438,19 @@ func _on_enter_world_pressed(world_id: int, button: Button) -> void:
|
||||
func _on_edit_world_pressed(world_id: int) -> void:
|
||||
Session.begin_edit(world_id)
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user