diff --git a/backend/app/routers/social_friends.py b/backend/app/routers/social_friends.py index 842bf77..82196ba 100644 --- a/backend/app/routers/social_friends.py +++ b/backend/app/routers/social_friends.py @@ -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 = 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: 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) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 6ba19b5..23be0a4 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -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, diff --git a/backend/tests/test_social_friends.py b/backend/tests/test_social_friends.py index c2957ff..7936a73 100644 --- a/backend/tests/test_social_friends.py +++ b/backend/tests/test_social_friends.py @@ -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 diff --git a/project.godot b/project.godot index 04a0216..d4efc50 100644 --- a/project.godot +++ b/project.godot @@ -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" diff --git a/scenes/friends_presence_ui.tscn b/scenes/friends_presence_ui.tscn index 049e256..1d9ff7b 100644 --- a/scenes/friends_presence_ui.tscn +++ b/scenes/friends_presence_ui.tscn @@ -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) diff --git a/scripts/api_client.gd b/scripts/api_client.gd index a42381b..25e0a7c 100644 --- a/scripts/api_client.gd +++ b/scripts/api_client.gd @@ -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: diff --git a/scripts/audio_manager.gd b/scripts/audio_manager.gd new file mode 100644 index 0000000..f1f002b --- /dev/null +++ b/scripts/audio_manager.gd @@ -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() diff --git a/scripts/avatar/avatar_customization_ui.gd b/scripts/avatar/avatar_customization_ui.gd index f2b8769..362d859 100644 --- a/scripts/avatar/avatar_customization_ui.gd +++ b/scripts/avatar/avatar_customization_ui.gd @@ -1,36 +1,104 @@ ## AvatarCustomizationUI ## ## The in-game avatar customization screen. Lets the player: -## - See a live preview of their avatar. -## - Browse and equip/unequip items per slot. -## - Adjust skin tone. -## - Save / load loadouts. +## - See a live 3D preview of their avatar with turntable rotation and camera zoom/framing. +## - Browse category tabs: Body & Head, Clothing, Accessories, Skin & Colors. +## - Browse and equip/unequip friendly-named items per slot. +## - Fine-tune skin tone with presets, shade slider, and custom RGB sliders. +## - View live Buffalo Chips balance and automatic save status. +## - Auto-save on exit (persisting to user://loadout.json, AvatarSystem, and backend). ## -## The UI is built programmatically so it works without a pre-authored .tscn. -## It is a Control node that can be added to any scene. +## Built programmatically as a full-screen Control node. class_name AvatarCustomizationUI extends Control -# Preloaded rather than referenced by global class_name -- see avatar_loadout.gd. +# Preloaded dependencies. const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd") const AvatarCatalog := preload("res://scripts/avatar/avatar_catalog.gd") const AvatarLoader := preload("res://scripts/avatar/avatar_loader.gd") const AvatarVisualizer := preload("res://scripts/avatar/avatar_visualizer.gd") const AvatarSlotRegistry := preload("res://scripts/avatar/avatar_slot_registry.gd") +const EconomyApiClient := preload("res://scripts/economy_api_client.gd") # --------------------------------------------------------------------------- # Signals # --------------------------------------------------------------------------- -## Emitted when the player closes the customization screen. +## Emitted when the player backs out or closes the screen. signal closed +## Emitted when the player clicks the back button or presses ESC. +signal back_requested + ## Emitted when the loadout changes (after equip/unequip/skin-tone). signal loadout_changed(loadout: AvatarLoadout) +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +## Category tabs definition per Lead Architect blueprint: +## Tab 0: "Body & Head" (slots: base_body, head, hair, face) +## Tab 1: "Clothing" (slots: chest, pants, torso, left_foot, left_hand) +## Tab 2: "Accessories" (slots: back, accessory) +## Tab 3: "Skin & Colors" (skin tone presets, shading slider, custom RGB sliders) +const CATEGORIES: Array[Dictionary] = [ + { + "name": "Body & Head", + "slots": ["base_body", "head", "hair", "face"] + }, + { + "name": "Clothing", + "slots": ["chest", "pants", "torso", "left_foot", "left_hand"] + }, + { + "name": "Accessories", + "slots": ["back", "accessory"] + }, + { + "name": "Skin & Colors", + "slots": [] + } +] + +## Camera framing presets: +## "Full Body" (dist 3.5, height 0.98) +## "Head" (dist 1.6, height 1.62) +## "Torso" (dist 2.2, height 1.12) +## "Feet" (dist 1.8, height 0.35) +const CAMERA_PRESETS: Dictionary = { + "Full Body": {"dist": 3.5, "height": 0.98}, + "Head": {"dist": 1.6, "height": 1.62}, + "Torso": {"dist": 2.2, "height": 1.12}, + "Feet": {"dist": 1.8, "height": 0.35}, +} + +## Skin: base swatches. First swatch is default yellow. +const SKIN_SWATCHES: Array[Color] = [ + Color(0.96, 0.80, 0.19), # yellow (default) + Color(0.95, 0.78, 0.66), # pale + Color(0.83, 0.62, 0.46), # tan + Color(0.55, 0.38, 0.27), # brown + Color(0.33, 0.21, 0.15), # deep brown + Color(0.62, 0.62, 0.64), # grey + Color(0.78, 0.24, 0.24), # red + Color(0.30, 0.62, 0.34), # green + Color(0.26, 0.46, 0.76), # blue + Color(0.62, 0.36, 0.72), # purple +] + +const _TURNTABLE_IDLE_SPIN_DELAY := 2.5 +const _TURNTABLE_IDLE_SPIN_SPEED := 0.5 +const _TURNTABLE_DRAG_SPEED := 0.01 + +const CAMERA_MIN_DIST := 1.5 +const CAMERA_MAX_DIST := 4.5 +const CAMERA_ZOOM_STEP := 0.2 + + # --------------------------------------------------------------------------- # Exports / config # --------------------------------------------------------------------------- @@ -52,10 +120,10 @@ var loadout: AvatarLoadout = null ## The AvatarSystem singleton (set in _ready). var _system: Node = null -## The catalog (owned by AvatarSystem). +## The catalog (owned by AvatarSystem or local fallback). var _catalog: AvatarCatalog = null -## The loader (owned by AvatarSystem). +## The loader (owned by AvatarSystem or local fallback). var _loader: AvatarLoader = null ## The visualizer that renders the preview. @@ -73,106 +141,216 @@ var _preview_camera: Camera3D = null ## The preview avatar root. var _preview_root: Node3D = null -## Currently selected slot id. -var _selected_slot: String = "" +## Camera framing parameters. +var _camera_distance: float = 3.5 +var _camera_target_height: float = 0.98 -## Skin: a base colour picked from a swatch, plus a shade multiplier from the -## slider (0.5 = the swatch as-is, lower = darker, higher = lighter). The -## first swatch is the placeholder default yellow. -const SKIN_SWATCHES: Array[Color] = [ - Color(0.96, 0.80, 0.19), # yellow (default) - Color(0.95, 0.78, 0.66), # pale - Color(0.83, 0.62, 0.46), # tan - Color(0.55, 0.38, 0.27), # brown - Color(0.33, 0.21, 0.15), # deep brown - Color(0.62, 0.62, 0.64), # grey - Color(0.78, 0.24, 0.24), # red - Color(0.30, 0.62, 0.34), # green - Color(0.26, 0.46, 0.76), # blue - Color(0.62, 0.36, 0.72), # purple -] +var camera_distance: float: + get: + return _camera_distance + +var camera_target_height: float: + get: + return _camera_target_height + +## Currently selected category index. +var _current_category_idx: int = 0 + +## Currently selected slot id. +var _selected_slot: String = "base_body" + +## Current skin base color before shading. var _skin_base: Color = SKIN_SWATCHES[0] -## Turntable: the preview avatar's Y rotation (radians), whether the player is -## currently dragging it, and how long since they last touched it (drives the -## slow idle auto-spin). Starts facing the camera. +## Turntable state. var _turntable_yaw: float = PI var _turntable_dragging: bool = false var _turntable_idle: float = 0.0 -const _TURNTABLE_IDLE_SPIN_DELAY := 2.5 -const _TURNTABLE_IDLE_SPIN_SPEED := 0.5 -const _TURNTABLE_DRAG_SPEED := 0.01 ## UI node references. -var _slot_list: VBoxContainer = null +var _back_button: Button = null +var _title_label: Label = null +var _wallet_label: Label = null +var _status_pill: Label = null + +var _zoom_slider: HSlider = null + +var _category_tab_bar: TabBar = null +var _items_section: VBoxContainer = null +var _skin_section: VBoxContainer = null + +var _slot_list: HBoxContainer = null var _item_list: VBoxContainer = null -var _slot_label: Label = null -var _item_label: Label = null var _skin_tone_slider: HSlider = null var _status_label: Label = null +var _slider_r: HSlider = null +var _slider_g: HSlider = null +var _slider_b: HSlider = null +var _label_r: Label = null +var _label_g: Label = null +var _label_b: Label = null +var _skin_preview: ColorRect = null + # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: - # Grab the AvatarSystem autoload. + # Grab the AvatarSystem autoload or instantiate fallback. _system = get_node_or_null("/root/AvatarSystem") - if _system == null: - push_error("AvatarCustomizationUI: AvatarSystem autoload not found") - return - - _catalog = _system.catalog - _loader = _system.loader + if _system != null: + _catalog = _system.catalog + _loader = _system.loader + else: + _catalog = AvatarCatalog.new() + _loader = AvatarLoader.new() # Load the catalog if not already loaded. if _catalog != null and not _catalog.is_loaded(): _catalog.load_catalog(catalog_path) - # Build the UI. + # Build the UI tree. _build_ui() - # Seed from the player's live loadout (set at login) if there is one, so - # the screen opens showing what they're actually wearing; otherwise start - # from a bare default. + # Seed from the player's live loadout (set at login) if there is one. if _system != null and _system.current_loadout != null: loadout = AvatarLoadout.from_json(_system.current_loadout.to_json()) if loadout == null or not loadout.validate(): loadout = AvatarLoadout.new() loadout.base_body = default_base_body - # Initialize the visualizer. + # Sync skin tone if already present in loadout. + if loadout.skin_tone != Color.TRANSPARENT: + _skin_base = loadout.skin_tone + _sync_skin_controls() + + # Initialize visualizer. _visualizer = AvatarVisualizer.new() - # Populate the slot list. - _populate_slots() + # Initialize category tabs to Tab 0: "Body & Head". + _on_category_tab_changed(0) - # Select the first slot. - if AvatarSlotRegistry.SLOT_IDS.size() > 0: - _select_slot(AvatarSlotRegistry.SLOT_IDS[0]) + # Initial camera framing. + _update_camera_transform() - # Refresh the preview. + # Refresh the 3D preview. _refresh_preview() + # Fetch live Buffalo Chips balance. + _fetch_wallet_balance() + + +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed("ui_cancel"): + get_viewport().set_input_as_handled() + save_and_exit() + # --------------------------------------------------------------------------- -# UI construction +# UI Construction # --------------------------------------------------------------------------- -## Build the entire UI tree programmatically. func _build_ui() -> void: - # Root layout: horizontal split (preview | controls). + # Top Level Layout: vertical split (HeaderBar | ContentArea). + var main_vbox := VBoxContainer.new() + main_vbox.name = "MainLayout" + main_vbox.set_anchors_preset(Control.PRESET_FULL_RECT) + main_vbox.add_theme_constant_override("separation", 0) + add_child(main_vbox) + + # --- Header Bar --- + var header_panel := PanelContainer.new() + header_panel.name = "HeaderBar" + var header_sb := StyleBoxFlat.new() + header_sb.bg_color = Color(0.08, 0.09, 0.12, 0.98) + header_sb.set_content_margin_all(10) + header_sb.content_margin_left = 16 + header_sb.content_margin_right = 16 + header_panel.add_theme_stylebox_override("panel", header_sb) + main_vbox.add_child(header_panel) + + var header_hbox := HBoxContainer.new() + header_hbox.name = "HeaderHBox" + header_hbox.add_theme_constant_override("separation", 16) + header_panel.add_child(header_hbox) + + # Back button: < Back [ESC] (custom_minimum_size Vector2(120, 38)) + _back_button = Button.new() + _back_button.name = "BackButton" + _back_button.text = "< Back [ESC]" + _back_button.custom_minimum_size = Vector2(120, 38) + _back_button.pressed.connect(_on_back_pressed) + header_hbox.add_child(_back_button) + + # Title Label: Avatar Studio (font_size 24+, centered or expand fill) + _title_label = Label.new() + _title_label.name = "TitleLabel" + _title_label.text = "Avatar Studio" + _title_label.add_theme_font_size_override("font_size", 24) + _title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _title_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + header_hbox.add_child(_title_label) + + # Header Right container + var header_right := HBoxContainer.new() + header_right.name = "HeaderRight" + header_right.add_theme_constant_override("separation", 14) + header_hbox.add_child(header_right) + + # Status pill label: All changes saved + var status_pill_panel := PanelContainer.new() + status_pill_panel.name = "StatusPillPanel" + var pill_sb := StyleBoxFlat.new() + pill_sb.bg_color = Color(0.12, 0.22, 0.15, 0.9) + pill_sb.border_color = Color(0.25, 0.75, 0.4, 0.6) + pill_sb.set_border_width_all(1) + pill_sb.set_corner_radius_all(12) + pill_sb.content_margin_left = 12 + pill_sb.content_margin_right = 12 + pill_sb.content_margin_top = 4 + pill_sb.content_margin_bottom = 4 + status_pill_panel.add_theme_stylebox_override("panel", pill_sb) + header_right.add_child(status_pill_panel) + + _status_pill = Label.new() + _status_pill.name = "StatusPill" + _status_pill.text = "All changes saved" + _status_pill.add_theme_color_override("font_color", Color(0.4, 0.95, 0.6)) + _status_pill.add_theme_font_size_override("font_size", 13) + status_pill_panel.add_child(_status_pill) + + # Live Buffalo Chips balance label + _wallet_label = Label.new() + _wallet_label.name = "WalletBalanceLabel" + _wallet_label.text = "Wallet: 0 Buffalo Chips" + _wallet_label.add_theme_font_size_override("font_size", 14) + _wallet_label.add_theme_color_override("font_color", Color(0.96, 0.82, 0.3)) + header_right.add_child(_wallet_label) + + # Content Area: Margins around horizontal split (preview | controls). + var content_margin := MarginContainer.new() + content_margin.name = "ContentMargin" + content_margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL + content_margin.size_flags_vertical = Control.SIZE_EXPAND_FILL + content_margin.add_theme_constant_override("margin_left", 12) + content_margin.add_theme_constant_override("margin_right", 12) + content_margin.add_theme_constant_override("margin_top", 12) + content_margin.add_theme_constant_override("margin_bottom", 12) + main_vbox.add_child(content_margin) + var root_hbox := HBoxContainer.new() root_hbox.name = "RootLayout" - root_hbox.set_anchors_preset(Control.PRESET_FULL_RECT) + root_hbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + root_hbox.size_flags_vertical = Control.SIZE_EXPAND_FILL root_hbox.add_theme_constant_override("separation", 16) - add_child(root_hbox) + content_margin.add_child(root_hbox) - # --- Left: preview (takes all the leftover width) --- + # --- Left: preview panel (SubViewport 3D with turntable & camera controls) --- var preview_panel := PanelContainer.new() preview_panel.name = "PreviewPanel" - preview_panel.custom_minimum_size = Vector2(420, 0) + preview_panel.custom_minimum_size = Vector2(430, 0) preview_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL root_hbox.add_child(preview_panel) @@ -190,7 +368,7 @@ func _build_ui() -> void: _preview_container = SubViewportContainer.new() _preview_container.name = "PreviewViewportContainer" _preview_container.stretch = true - _preview_container.custom_minimum_size = Vector2(400, 520) + _preview_container.custom_minimum_size = Vector2(400, 480) _preview_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL _preview_container.size_flags_vertical = Control.SIZE_EXPAND_FILL _preview_container.mouse_filter = Control.MOUSE_FILTER_STOP @@ -198,23 +376,81 @@ func _build_ui() -> void: preview_vbox.add_child(_preview_container) var rotate_hint := Label.new() - rotate_hint.text = "drag to rotate" + rotate_hint.text = "drag to rotate • scroll wheel to zoom" rotate_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER rotate_hint.add_theme_color_override("font_color", Color(1, 1, 1, 0.5)) rotate_hint.add_theme_font_size_override("font_size", 12) preview_vbox.add_child(rotate_hint) + # --- Camera Controls Bar --- + var cam_bar := VBoxContainer.new() + cam_bar.name = "CameraControlsBar" + cam_bar.add_theme_constant_override("separation", 4) + preview_vbox.add_child(cam_bar) + + # Preset buttons row: "Full Body", "Head", "Torso", "Feet" + var preset_row := HBoxContainer.new() + preset_row.name = "PresetRow" + preset_row.alignment = BoxContainer.ALIGNMENT_CENTER + preset_row.add_theme_constant_override("separation", 6) + cam_bar.add_child(preset_row) + + for preset_name in ["Full Body", "Head", "Torso", "Feet"]: + var pbtn := Button.new() + pbtn.name = "Preset_%s" % preset_name.replace(" ", "") + pbtn.text = preset_name + pbtn.custom_minimum_size = Vector2(82, 28) + pbtn.add_theme_font_size_override("font_size", 12) + pbtn.pressed.connect(frame_camera_preset.bind(preset_name)) + preset_row.add_child(pbtn) + + # Zoom controls row: [-] Slider [+] + var zoom_row := HBoxContainer.new() + zoom_row.name = "ZoomRow" + zoom_row.alignment = BoxContainer.ALIGNMENT_CENTER + zoom_row.add_theme_constant_override("separation", 8) + cam_bar.add_child(zoom_row) + + var zoom_lbl := Label.new() + zoom_lbl.text = "Zoom:" + zoom_lbl.add_theme_font_size_override("font_size", 12) + zoom_row.add_child(zoom_lbl) + + var zoom_out_btn := Button.new() + zoom_out_btn.name = "ZoomOutBtn" + zoom_out_btn.text = "-" + zoom_out_btn.custom_minimum_size = Vector2(28, 24) + zoom_out_btn.pressed.connect(func(): set_camera_zoom(_camera_distance + CAMERA_ZOOM_STEP)) + zoom_row.add_child(zoom_out_btn) + + _zoom_slider = HSlider.new() + _zoom_slider.name = "CameraZoomSlider" + _zoom_slider.min_value = CAMERA_MIN_DIST + _zoom_slider.max_value = CAMERA_MAX_DIST + _zoom_slider.step = 0.05 + _zoom_slider.value = _camera_distance + _zoom_slider.custom_minimum_size = Vector2(160, 20) + _zoom_slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _zoom_slider.value_changed.connect(_on_zoom_slider_changed) + zoom_row.add_child(_zoom_slider) + + var zoom_in_btn := Button.new() + zoom_in_btn.name = "ZoomInBtn" + zoom_in_btn.text = "+" + zoom_in_btn.custom_minimum_size = Vector2(28, 24) + zoom_in_btn.pressed.connect(func(): set_camera_zoom(_camera_distance - CAMERA_ZOOM_STEP)) + zoom_row.add_child(zoom_in_btn) + + # 3D Viewport Setup. _preview_viewport = SubViewport.new() _preview_viewport.name = "PreviewViewport" - _preview_viewport.size = Vector2i(400, 520) + _preview_viewport.size = Vector2i(400, 480) _preview_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS _preview_viewport.transparent_bg = true - # Its own 3D world -- a SubViewport that shares the (Control-only) main - # world has no scenario/environment set up and renders nothing. _preview_viewport.own_world_3d = true _preview_container.add_child(_preview_viewport) - # Environment: ambient fill so the model reads even from a bad light angle. + # 3D Environment. var world_env := WorldEnvironment.new() world_env.name = "PreviewEnv" var env := Environment.new() @@ -226,105 +462,130 @@ func _build_ui() -> void: world_env.environment = env _preview_viewport.add_child(world_env) - # Camera for the preview -- framed to fit the full ~2 m avatar. + # Preview Camera. _preview_camera = Camera3D.new() _preview_camera.name = "PreviewCamera" - # look_at needs a global transform, which only exists once in the tree. - _preview_camera.look_at_from_position( - Vector3(0, 1.05, 3.5), Vector3(0, 0.98, 0), Vector3.UP) _preview_camera.current = true _preview_viewport.add_child(_preview_camera) - # Avatar root inside the viewport. + # Preview Avatar Root. _preview_root = Node3D.new() _preview_root.name = "AvatarPreviewRoot" _preview_viewport.add_child(_preview_root) - # Key + fill lights so the avatar is clearly visible. + # Directional Lights. var key := DirectionalLight3D.new() key.name = "PreviewKeyLight" key.rotation_degrees = Vector3(-35, 35, 0) key.light_energy = 1.0 _preview_viewport.add_child(key) + var fill := DirectionalLight3D.new() fill.name = "PreviewFillLight" fill.rotation_degrees = Vector3(-20, -140, 0) fill.light_energy = 0.35 _preview_viewport.add_child(fill) - # --- Right: controls --- + # --- Right: Controls Panel --- var controls_panel := PanelContainer.new() controls_panel.name = "ControlsPanel" - controls_panel.custom_minimum_size = Vector2(520, 0) + controls_panel.custom_minimum_size = Vector2(540, 0) + controls_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL root_hbox.add_child(controls_panel) var controls_margin := MarginContainer.new() - controls_margin.add_theme_constant_override("margin_left", 12) - controls_margin.add_theme_constant_override("margin_right", 12) + controls_margin.add_theme_constant_override("margin_left", 14) + controls_margin.add_theme_constant_override("margin_right", 14) controls_margin.add_theme_constant_override("margin_top", 12) controls_margin.add_theme_constant_override("margin_bottom", 12) controls_panel.add_child(controls_margin) var controls_vbox := VBoxContainer.new() controls_vbox.name = "Controls" - controls_vbox.add_theme_constant_override("separation", 8) + controls_vbox.add_theme_constant_override("separation", 10) controls_margin.add_child(controls_vbox) - # Title. - var title := Label.new() - title.text = "Avatar Customization" - title.add_theme_font_size_override("font_size", 24) - controls_vbox.add_child(title) + # Category Tabs + _category_tab_bar = TabBar.new() + _category_tab_bar.name = "CategoryTabBar" + _category_tab_bar.custom_minimum_size = Vector2(0, 38) + for cat in CATEGORIES: + _category_tab_bar.add_tab(cat["name"]) + _category_tab_bar.tab_changed.connect(_on_category_tab_changed) + controls_vbox.add_child(_category_tab_bar) - # Slot selector. + # --- Items Section (Tabs 0, 1, 2) --- + _items_section = VBoxContainer.new() + _items_section.name = "ItemsSection" + _items_section.size_flags_vertical = Control.SIZE_EXPAND_FILL + _items_section.add_theme_constant_override("separation", 8) + controls_vbox.add_child(_items_section) + + # Slots header var slot_header := Label.new() - slot_header.text = "Slots" - slot_header.add_theme_font_size_override("font_size", 16) - controls_vbox.add_child(slot_header) + slot_header.name = "SlotHeader" + slot_header.text = "Select Slot" + slot_header.add_theme_font_size_override("font_size", 15) + _items_section.add_child(slot_header) var slot_scroll := ScrollContainer.new() - slot_scroll.custom_minimum_size = Vector2(0, 160) - controls_vbox.add_child(slot_scroll) + slot_scroll.custom_minimum_size = Vector2(0, 48) + slot_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO + slot_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + _items_section.add_child(slot_scroll) - _slot_list = VBoxContainer.new() + _slot_list = HBoxContainer.new() _slot_list.name = "SlotList" - _slot_list.add_theme_constant_override("separation", 4) + _slot_list.add_theme_constant_override("separation", 6) slot_scroll.add_child(_slot_list) - # Item selector for the selected slot. + # Items header var item_header := Label.new() - item_header.text = "Items" - item_header.add_theme_font_size_override("font_size", 16) - controls_vbox.add_child(item_header) + item_header.name = "ItemHeader" + item_header.text = "Available Items" + item_header.add_theme_font_size_override("font_size", 15) + _items_section.add_child(item_header) var item_scroll := ScrollContainer.new() - item_scroll.custom_minimum_size = Vector2(0, 200) - controls_vbox.add_child(item_scroll) + item_scroll.name = "ItemScroll" + item_scroll.custom_minimum_size = Vector2(0, 240) + item_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + _items_section.add_child(item_scroll) _item_list = VBoxContainer.new() _item_list.name = "ItemList" - _item_list.add_theme_constant_override("separation", 4) + _item_list.add_theme_constant_override("separation", 6) + _item_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL item_scroll.add_child(_item_list) - # Skin colour: a row of preset swatches + a shade slider that darkens or - # lightens the picked colour. The first swatch is the default yellow. - var skin_header := Label.new() - skin_header.text = "Skin Colour" - skin_header.add_theme_font_size_override("font_size", 16) - controls_vbox.add_child(skin_header) + # --- Skin & Colors Section (Tab 3) --- + _skin_section = VBoxContainer.new() + _skin_section.name = "SkinSection" + _skin_section.size_flags_vertical = Control.SIZE_EXPAND_FILL + _skin_section.add_theme_constant_override("separation", 10) + _skin_section.visible = false + controls_vbox.add_child(_skin_section) + + # Presets + var preset_header := Label.new() + preset_header.text = "Skin Tone Presets" + preset_header.add_theme_font_size_override("font_size", 15) + _skin_section.add_child(preset_header) var swatch_grid := GridContainer.new() + swatch_grid.name = "SwatchGrid" swatch_grid.columns = 5 - swatch_grid.add_theme_constant_override("h_separation", 4) - swatch_grid.add_theme_constant_override("v_separation", 4) - controls_vbox.add_child(swatch_grid) + swatch_grid.add_theme_constant_override("h_separation", 6) + swatch_grid.add_theme_constant_override("v_separation", 6) + _skin_section.add_child(swatch_grid) for c in SKIN_SWATCHES: swatch_grid.add_child(_make_swatch(c)) - var shade_label := Label.new() - shade_label.text = "Shade" - shade_label.add_theme_font_size_override("font_size", 13) - controls_vbox.add_child(shade_label) + # Shading + var shade_header := Label.new() + shade_header.text = "Shading (Dark to Light)" + shade_header.add_theme_font_size_override("font_size", 15) + _skin_section.add_child(shade_header) _skin_tone_slider = HSlider.new() _skin_tone_slider.name = "ShadeSlider" @@ -334,9 +595,107 @@ func _build_ui() -> void: _skin_tone_slider.value = 0.5 _skin_tone_slider.custom_minimum_size = Vector2(0, 24) _skin_tone_slider.value_changed.connect(_on_skin_tone_changed) - controls_vbox.add_child(_skin_tone_slider) + _skin_section.add_child(_skin_tone_slider) - # Status / error label. + # Custom RGB Sliders + var rgb_header := Label.new() + rgb_header.text = "Custom RGB Color" + rgb_header.add_theme_font_size_override("font_size", 15) + _skin_section.add_child(rgb_header) + + var rgb_container := VBoxContainer.new() + rgb_container.name = "RGBContainer" + rgb_container.add_theme_constant_override("separation", 6) + _skin_section.add_child(rgb_container) + + # Red + var r_row := HBoxContainer.new() + r_row.add_theme_constant_override("separation", 10) + var r_name := Label.new() + r_name.text = "Red:" + r_name.custom_minimum_size = Vector2(50, 0) + r_row.add_child(r_name) + _slider_r = HSlider.new() + _slider_r.name = "SliderR" + _slider_r.min_value = 0.0 + _slider_r.max_value = 1.0 + _slider_r.step = 0.01 + _slider_r.value = _skin_base.r + _slider_r.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _slider_r.value_changed.connect(_on_rgb_slider_changed) + r_row.add_child(_slider_r) + _label_r = Label.new() + _label_r.name = "LabelR" + _label_r.custom_minimum_size = Vector2(60, 0) + _label_r.text = "R: %d%%" % int(_skin_base.r * 100) + r_row.add_child(_label_r) + rgb_container.add_child(r_row) + + # Green + var g_row := HBoxContainer.new() + g_row.add_theme_constant_override("separation", 10) + var g_name := Label.new() + g_name.text = "Green:" + g_name.custom_minimum_size = Vector2(50, 0) + g_row.add_child(g_name) + _slider_g = HSlider.new() + _slider_g.name = "SliderG" + _slider_g.min_value = 0.0 + _slider_g.max_value = 1.0 + _slider_g.step = 0.01 + _slider_g.value = _skin_base.g + _slider_g.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _slider_g.value_changed.connect(_on_rgb_slider_changed) + g_row.add_child(_slider_g) + _label_g = Label.new() + _label_g.name = "LabelG" + _label_g.custom_minimum_size = Vector2(60, 0) + _label_g.text = "G: %d%%" % int(_skin_base.g * 100) + g_row.add_child(_label_g) + rgb_container.add_child(g_row) + + # Blue + var b_row := HBoxContainer.new() + b_row.add_theme_constant_override("separation", 10) + var b_name := Label.new() + b_name.text = "Blue:" + b_name.custom_minimum_size = Vector2(50, 0) + b_row.add_child(b_name) + _slider_b = HSlider.new() + _slider_b.name = "SliderB" + _slider_b.min_value = 0.0 + _slider_b.max_value = 1.0 + _slider_b.step = 0.01 + _slider_b.value = _skin_base.b + _slider_b.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _slider_b.value_changed.connect(_on_rgb_slider_changed) + b_row.add_child(_slider_b) + _label_b = Label.new() + _label_b.name = "LabelB" + _label_b.custom_minimum_size = Vector2(60, 0) + _label_b.text = "B: %d%%" % int(_skin_base.b * 100) + b_row.add_child(_label_b) + rgb_container.add_child(b_row) + + # Swatch preview + var preview_row := HBoxContainer.new() + preview_row.add_theme_constant_override("separation", 10) + var prev_title := Label.new() + prev_title.text = "Preview Color:" + preview_row.add_child(prev_title) + _skin_preview = ColorRect.new() + _skin_preview.name = "SkinColorPreview" + _skin_preview.custom_minimum_size = Vector2(50, 26) + _skin_preview.color = _skin_base + preview_row.add_child(_skin_preview) + _skin_section.add_child(preview_row) + + # Spacer + var spacer := Control.new() + spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL + _skin_section.add_child(spacer) + + # --- Footer: Status & Actions --- _status_label = Label.new() _status_label.name = "StatusLabel" _status_label.text = "" @@ -344,82 +703,90 @@ func _build_ui() -> void: _status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART controls_vbox.add_child(_status_label) - # Action buttons. var button_row := HBoxContainer.new() - button_row.add_theme_constant_override("separation", 8) + button_row.add_theme_constant_override("separation", 10) controls_vbox.add_child(button_row) var save_btn := Button.new() + save_btn.name = "SaveButton" save_btn.text = "Save Loadout" + save_btn.custom_minimum_size = Vector2(110, 36) save_btn.pressed.connect(_on_save_pressed) button_row.add_child(save_btn) var load_btn := Button.new() + load_btn.name = "LoadButton" load_btn.text = "Load Loadout" + load_btn.custom_minimum_size = Vector2(110, 36) load_btn.pressed.connect(_on_load_pressed) button_row.add_child(load_btn) - var close_btn := Button.new() - close_btn.text = "Close" - close_btn.pressed.connect(_on_close_pressed) - button_row.add_child(close_btn) - # --------------------------------------------------------------------------- -# Slot / item population +# Category & Slot Population # --------------------------------------------------------------------------- -## Populate the slot list with buttons for each slot. -func _populate_slots() -> void: +func _on_category_tab_changed(tab_idx: int) -> void: + _current_category_idx = tab_idx + if _category_tab_bar != null and _category_tab_bar.current_tab != tab_idx: + _category_tab_bar.current_tab = tab_idx + + if tab_idx < 3: + if _items_section != null: + _items_section.visible = true + if _skin_section != null: + _skin_section.visible = false + + _populate_slots_for_category(tab_idx) + + var cat_slots: Array = CATEGORIES[tab_idx]["slots"] + if cat_slots.size() > 0: + if not (cat_slots.has(_selected_slot)): + _selected_slot = cat_slots[0] + # Update toggle states on slot buttons + if _slot_list != null: + for child in _slot_list.get_children(): + if child is Button: + var btn := child as Button + btn.button_pressed = (btn.name == "Slot_%s" % _selected_slot) + _populate_items() + else: + if _items_section != null: + _items_section.visible = false + if _skin_section != null: + _skin_section.visible = true + _sync_skin_controls() + + +## Populate slot buttons for the active category. +func _populate_slots_for_category(cat_idx: int) -> void: if _slot_list == null: return - # Clear existing children. for child in _slot_list.get_children(): + _slot_list.remove_child(child) child.queue_free() - for slot_id in AvatarSlotRegistry.SLOT_IDS: + if cat_idx < 0 or cat_idx >= CATEGORIES.size(): + return + + var slots: Array = CATEGORIES[cat_idx]["slots"] + for slot_id in slots: var btn := Button.new() btn.name = "Slot_%s" % slot_id - btn.text = slot_id + btn.text = _format_slot_name(slot_id) btn.toggle_mode = true + btn.button_pressed = (slot_id == _selected_slot) + btn.custom_minimum_size = Vector2(100, 34) btn.pressed.connect(_on_slot_button_pressed.bind(slot_id)) _slot_list.add_child(btn) -## Populate the item list for the currently selected slot. -func _populate_items() -> void: - if _item_list == null or _selected_slot.is_empty(): - return - - # Clear existing children. - for child in _item_list.get_children(): - child.queue_free() - - if _catalog == null: - return - - # Get assets for this slot. - var assets: Array[String] = _catalog.get_assets_by_slot(_selected_slot) - - # "None" button to unequip. - var none_btn := Button.new() - none_btn.text = "(none)" - none_btn.pressed.connect(_on_equip_pressed.bind("")) - _item_list.add_child(none_btn) - - for asset_id in assets: - var btn := Button.new() - btn.text = asset_id - btn.pressed.connect(_on_equip_pressed.bind(asset_id)) - _item_list.add_child(btn) +## Populate the slot list (alias for compatibility). +func _populate_slots() -> void: + _populate_slots_for_category(_current_category_idx) -# --------------------------------------------------------------------------- -# Slot selection -# --------------------------------------------------------------------------- - -## Handle a slot button press. func _on_slot_button_pressed(slot_id: String) -> void: _select_slot(slot_id) @@ -428,7 +795,24 @@ func _on_slot_button_pressed(slot_id: String) -> void: func _select_slot(slot_id: String) -> void: _selected_slot = slot_id - # Update button toggle states. + # Switch category if the target slot belongs to a different tab. + var target_cat := -1 + for i in range(CATEGORIES.size()): + if CATEGORIES[i]["slots"].has(slot_id): + target_cat = i + break + + if target_cat != -1 and target_cat != _current_category_idx: + _current_category_idx = target_cat + if _category_tab_bar != null: + _category_tab_bar.current_tab = target_cat + if _items_section != null: + _items_section.visible = true + if _skin_section != null: + _skin_section.visible = false + _populate_slots_for_category(target_cat) + + # Update toggle states on slot buttons. if _slot_list != null: for child in _slot_list.get_children(): if child is Button: @@ -438,30 +822,219 @@ func _select_slot(slot_id: String) -> void: _populate_items() +func _format_slot_name(slot_id: String) -> String: + match slot_id: + "base_body": return "Base Body" + "left_foot": return "Left Foot" + "right_foot": return "Right Foot" + "left_hand": return "Left Hand" + "right_hand": return "Right Hand" + _: return slot_id.capitalize() + + # --------------------------------------------------------------------------- -# Equip / unequip +# Item Population & Human-Friendly Names +# --------------------------------------------------------------------------- + +## Populate the item list for the currently selected slot. +func _populate_items() -> void: + if _item_list == null or _selected_slot.is_empty(): + return + + for child in _item_list.get_children(): + _item_list.remove_child(child) + child.queue_free() + + if _catalog == null: + return + + var assets: Array[String] = _catalog.get_assets_by_slot(_selected_slot) + if _selected_slot == "base_body" and assets.is_empty() and not default_base_body.is_empty(): + assets.append(default_base_body) + + # Determine if "(None)" is equipped for this slot. + var is_none_equipped: bool = false + if loadout != null: + if _selected_slot == "base_body": + is_none_equipped = loadout.base_body.is_empty() + elif _selected_slot == "accessory": + var accs: Array = loadout.slots.get("accessory", []) + is_none_equipped = accs.is_empty() + else: + is_none_equipped = loadout.get_equipment(_selected_slot).is_empty() + else: + is_none_equipped = true + + # (None) / Unequip option. + var none_btn := Button.new() + none_btn.name = "Item_none" + none_btn.custom_minimum_size = Vector2(0, 44) + none_btn.alignment = HORIZONTAL_ALIGNMENT_LEFT + none_btn.text = "(None)" if not is_none_equipped else "(None) ✓ EQUIPPED" + none_btn.set_meta("asset_id", "") + none_btn.set_meta("display_name", "(None)") + none_btn.set_meta("is_equipped", is_none_equipped) + _style_item_button(none_btn, is_none_equipped) + if is_none_equipped: + var badge := Label.new() + badge.name = "EquippedBadge" + badge.text = "✓ EQUIPPED" + badge.add_theme_color_override("font_color", Color(0.3, 0.9, 0.5)) + none_btn.add_child(badge) + none_btn.pressed.connect(_on_equip_pressed.bind("")) + _item_list.add_child(none_btn) + + # Catalog items with friendly names and equipped badges. + for asset_id in assets: + var dname := get_friendly_item_name(asset_id) + var item_equipped: bool = false + if loadout != null: + if _selected_slot == "base_body": + item_equipped = (loadout.base_body == asset_id) + elif _selected_slot == "accessory": + item_equipped = loadout.has_accessory(asset_id) + else: + item_equipped = (loadout.get_equipment(_selected_slot) == asset_id) + + var btn := Button.new() + btn.name = "Item_%s" % asset_id + btn.custom_minimum_size = Vector2(0, 44) + btn.alignment = HORIZONTAL_ALIGNMENT_LEFT + btn.text = dname if not item_equipped else "%s ✓ EQUIPPED" % dname + btn.set_meta("asset_id", asset_id) + btn.set_meta("display_name", dname) + btn.set_meta("is_equipped", item_equipped) + _style_item_button(btn, item_equipped) + if item_equipped: + var badge := Label.new() + badge.name = "EquippedBadge" + badge.text = "✓ EQUIPPED" + badge.add_theme_color_override("font_color", Color(0.3, 0.9, 0.5)) + btn.add_child(badge) + btn.pressed.connect(_on_equip_pressed.bind(asset_id)) + _item_list.add_child(btn) + + +## Look up the display name from the catalog, falling back to a capitalized ID. +func get_friendly_item_name(asset_id: String) -> String: + if asset_id.is_empty(): + return "(None)" + var item: Dictionary = _catalog.resolve(asset_id) if _catalog != null else {} + var dname := str(item.get("display_name", "")).strip_edges() + return dname if not dname.is_empty() else asset_id.capitalize() + + +func _style_item_button(btn: Button, is_equipped: bool) -> void: + var sb_normal := StyleBoxFlat.new() + sb_normal.set_corner_radius_all(6) + sb_normal.set_content_margin_all(8) + if is_equipped: + sb_normal.bg_color = Color(0.16, 0.24, 0.18, 0.95) + sb_normal.border_color = Color(0.25, 0.85, 0.45) + sb_normal.set_border_width_all(2) + else: + sb_normal.bg_color = Color(0.12, 0.14, 0.18, 0.85) + sb_normal.border_color = Color(0.24, 0.26, 0.32) + sb_normal.set_border_width_all(1) + btn.add_theme_stylebox_override("normal", sb_normal) + + var sb_hover := sb_normal.duplicate() as StyleBoxFlat + if is_equipped: + sb_hover.border_color = Color(0.4, 0.95, 0.6) + sb_hover.bg_color = Color(0.20, 0.28, 0.22, 1.0) + else: + sb_hover.border_color = Color(0.5, 0.6, 0.7) + sb_hover.bg_color = Color(0.18, 0.20, 0.26, 1.0) + btn.add_theme_stylebox_override("hover", sb_hover) + + var sb_pressed := sb_normal.duplicate() as StyleBoxFlat + sb_pressed.border_color = Color(0.3, 0.85, 0.5) + btn.add_theme_stylebox_override("pressed", sb_pressed) + + +# --------------------------------------------------------------------------- +# Equip / Unequip # --------------------------------------------------------------------------- -## Handle an equip/unequip action for the selected slot. func _on_equip_pressed(asset_id: String) -> void: if loadout == null or _selected_slot.is_empty(): return - if _selected_slot == "accessory": + if _selected_slot == "base_body": + if not asset_id.is_empty(): + loadout.base_body = asset_id + else: + loadout.base_body = default_base_body + elif _selected_slot == "accessory": if asset_id.is_empty(): - # Clear all accessories. loadout.slots["accessory"] = [] else: - loadout.add_accessory(asset_id) + if loadout.has_accessory(asset_id): + loadout.remove_accessory(asset_id) + else: + loadout.add_accessory(asset_id) else: - loadout.set_equipment(_selected_slot, asset_id) + if asset_id.is_empty(): + loadout.set_equipment(_selected_slot, "") + else: + if loadout.get_equipment(_selected_slot) == asset_id: + loadout.set_equipment(_selected_slot, "") + else: + loadout.set_equipment(_selected_slot, asset_id) _refresh_preview() + _populate_items() + _mark_saved_status() loadout_changed.emit(loadout) # --------------------------------------------------------------------------- -# Turntable (drag to rotate the preview, slow auto-spin when idle) +# Camera Zoom & Framing Presets +# --------------------------------------------------------------------------- + +## Set preview camera zoom distance, clamped between CAMERA_MIN_DIST and CAMERA_MAX_DIST. +func set_camera_zoom(dist: float) -> void: + _camera_distance = clampf(dist, CAMERA_MIN_DIST, CAMERA_MAX_DIST) + if _zoom_slider != null: + _zoom_slider.set_value_no_signal(_camera_distance) + _update_camera_transform() + + +## Frame the camera using a named preset ("Full Body", "Head", "Torso", "Feet"). +func frame_camera_preset(preset_name: String) -> void: + var matched_key := "" + for k in CAMERA_PRESETS: + if str(k).to_lower() == preset_name.to_lower(): + matched_key = k + break + if matched_key.is_empty(): + push_warning("AvatarCustomizationUI: Unknown camera preset '%s'" % preset_name) + return + + var data: Dictionary = CAMERA_PRESETS[matched_key] + _camera_distance = float(data["dist"]) + _camera_target_height = float(data["height"]) + if _zoom_slider != null: + _zoom_slider.set_value_no_signal(_camera_distance) + _update_camera_transform() + + +func _on_zoom_slider_changed(value: float) -> void: + set_camera_zoom(value) + + +func _update_camera_transform() -> void: + if _preview_camera == null: + return + _preview_camera.look_at_from_position( + Vector3(0, _camera_target_height + 0.07, _camera_distance), + Vector3(0, _camera_target_height, 0), + Vector3.UP + ) + + +# --------------------------------------------------------------------------- +# Turntable & Input # --------------------------------------------------------------------------- func _process(delta: float) -> void: @@ -477,55 +1050,62 @@ func _process(delta: float) -> void: func _on_preview_gui_input(event: InputEvent) -> void: - if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: - _turntable_dragging = event.pressed - _turntable_idle = 0.0 + if event is InputEventMouseButton and event.pressed: + if event.button_index == MOUSE_BUTTON_WHEEL_UP: + set_camera_zoom(_camera_distance - CAMERA_ZOOM_STEP) + elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN: + set_camera_zoom(_camera_distance + CAMERA_ZOOM_STEP) + elif event.button_index == MOUSE_BUTTON_LEFT: + _turntable_dragging = true + _turntable_idle = 0.0 + elif event is InputEventMouseButton and not event.pressed: + if event.button_index == MOUSE_BUTTON_LEFT: + _turntable_dragging = false + _turntable_idle = 0.0 elif event is InputEventMouseMotion and _turntable_dragging: _turntable_yaw -= event.relative.x * _TURNTABLE_DRAG_SPEED _turntable_idle = 0.0 # --------------------------------------------------------------------------- -# Preview refresh +# Preview Refresh # --------------------------------------------------------------------------- -## Rebuild the avatar preview from the current loadout. func _refresh_preview() -> void: if _visualizer == null or _preview_root == null: return - # Clear the preview root. for child in _preview_root.get_children(): + _preview_root.remove_child(child) child.queue_free() - # Validate the loadout. if loadout == null or loadout.base_body.is_empty(): - _status_label.text = "No base body selected." + if _status_label != null: + _status_label.text = "No base body selected." return - # Build the render tree. _visualizer.load_loadout(loadout, _preview_root) - # Show any validation errors. if not loadout.validation_errors.is_empty(): - _status_label.text = "\n".join(loadout.validation_errors) + if _status_label != null: + _status_label.text = "\n".join(loadout.validation_errors) else: - _status_label.text = "" + if _status_label != null: + _status_label.text = "" # --------------------------------------------------------------------------- -# Skin tone +# Skin & Colors # --------------------------------------------------------------------------- -## Build one clickable colour swatch. func _make_swatch(color: Color) -> Button: var b := Button.new() - b.custom_minimum_size = Vector2(40, 30) + b.custom_minimum_size = Vector2(44, 32) b.tooltip_text = "#" + color.to_html(false) for state in ["normal", "hover", "pressed", "focus", "disabled"]: var sb := StyleBoxFlat.new() sb.bg_color = color - sb.set_corner_radius_all(3) + sb.set_corner_radius_all(4) if state == "hover" or state == "pressed" or state == "focus": sb.set_border_width_all(2) sb.border_color = Color.WHITE @@ -536,6 +1116,9 @@ func _make_swatch(color: Color) -> Button: func _on_skin_swatch_pressed(color: Color) -> void: _skin_base = color + if _skin_tone_slider != null: + _skin_tone_slider.set_value_no_signal(0.5) + _sync_skin_controls() _apply_skin_colour() @@ -543,7 +1126,31 @@ func _on_skin_tone_changed(_value: float) -> void: _apply_skin_colour() -## Combine the picked swatch with the shade slider and push it to the loadout. +func _on_rgb_slider_changed(_value: float) -> void: + if _slider_r == null or _slider_g == null or _slider_b == null: + return + _skin_base = Color(_slider_r.value, _slider_g.value, _slider_b.value, 1.0) + _apply_skin_colour() + + +func _sync_skin_controls() -> void: + var col := _skin_base + if _slider_r != null: + _slider_r.set_value_no_signal(col.r) + if _slider_g != null: + _slider_g.set_value_no_signal(col.g) + if _slider_b != null: + _slider_b.set_value_no_signal(col.b) + if _label_r != null: + _label_r.text = "R: %d%%" % int(col.r * 100) + if _label_g != null: + _label_g.text = "G: %d%%" % int(col.g * 100) + if _label_b != null: + _label_b.text = "B: %d%%" % int(col.b * 100) + if _skin_preview != null: + _skin_preview.color = col + + func _apply_skin_colour() -> void: if loadout == null: return @@ -555,36 +1162,119 @@ func _apply_skin_colour() -> void: col = _skin_base.lerp(Color.WHITE, (shade - 0.5) * 1.3) col.a = 1.0 loadout.skin_tone = col + + if _label_r != null: + _label_r.text = "R: %d%%" % int(col.r * 100) + if _label_g != null: + _label_g.text = "G: %d%%" % int(col.g * 100) + if _label_b != null: + _label_b.text = "B: %d%%" % int(col.b * 100) + if _skin_preview != null: + _skin_preview.color = col + _refresh_preview() + _mark_saved_status() loadout_changed.emit(loadout) # --------------------------------------------------------------------------- -# Save / load +# Wallet & Economy # --------------------------------------------------------------------------- +func _fetch_wallet_balance() -> void: + if _wallet_label == null: + return + _wallet_label.text = "Wallet: 0 Buffalo Chips" + + var api := get_node_or_null("/root/ApiClient") + if api == null or not api.has_method("request"): + return + if api.has_method("has_token") and not api.has_token(): + return + + var res: Dictionary = await EconomyApiClient.get_wallet(api) + if res.get("ok", false) and res.has("data") and res["data"] is Dictionary: + var bal: int = int(res["data"].get("balance", 0)) + _wallet_label.text = "Wallet: %d Buffalo Chips" % bal + else: + _wallet_label.text = "Wallet: 0 Buffalo Chips" + + +func update_wallet_balance(balance: int) -> void: + if _wallet_label != null: + _wallet_label.text = "Wallet: %d Buffalo Chips" % balance + + +# --------------------------------------------------------------------------- +# Auto-Save & Exit +# --------------------------------------------------------------------------- + +func _mark_saved_status() -> void: + if _status_pill != null: + _status_pill.text = "All changes saved" + + +## Save loadout to local cache, sync to AvatarSystem, persist to backend, play chime, and exit. +func save_and_exit() -> void: + if _status_pill != null: + _status_pill.text = "Saving changes..." + + if loadout != null: + var dict: Dictionary = loadout.to_dict() + var json := loadout.to_json() + + # Write loadout JSON to user://loadout.json + var file := FileAccess.open("user://loadout.json", FileAccess.WRITE) + if file: + file.store_string(json) + file.close() + + # Sync to AvatarSystem autoload + if _system != null and _system.has_method("set_loadout_from_dict"): + _system.set_loadout_from_dict(dict) + + # Persist to backend if signed in + var api := get_node_or_null("/root/ApiClient") + if api != null and api.has_method("has_token") and api.has_token() and api.has_method("save_avatar_loadout"): + await api.save_avatar_loadout(dict) + + # Play chime SFX via AudioManager + var audio := get_node_or_null("/root/AudioManager") + if audio != null and audio.has_method("play_chime"): + audio.play_chime() + + if _status_pill != null: + _status_pill.text = "All changes saved" + + back_requested.emit() + closed.emit() + + func _on_save_pressed() -> void: if loadout == null: return - # Local cache (offline fallback / fast reopen). var json := loadout.to_json() var file := FileAccess.open("user://loadout.json", FileAccess.WRITE) if file: file.store_string(json) file.close() - # Apply to the live in-world avatar immediately. if _system != null: _system.set_loadout_from_dict(JSON.parse_string(json)) - # Persist to the backend if we're signed in. var api := get_node_or_null("/root/ApiClient") - if api != null and api.has_token(): - _status_label.text = "Saving…" + if api != null and api.has_method("has_token") and api.has_token(): + if _status_pill != null: + _status_pill.text = "Saving…" var res: Dictionary = await api.save_avatar_loadout(JSON.parse_string(json)) - _status_label.text = "Saved." if res.get("ok", false) else "Saved locally (backend: %s)" % res.get("detail", "error") + if _status_pill != null: + _status_pill.text = "All changes saved" if res.get("ok", false) else "Saved locally" + if not res.get("ok", false): + _status_label.text = "Backend save error: %s" % res.get("detail", "error") else: + if _status_pill != null: + _status_pill.text = "All changes saved" _status_label.text = "Saved locally (not signed in)." @@ -608,14 +1298,23 @@ func _on_load_pressed() -> void: loadout = loaded _refresh_preview() + _populate_items() + if loadout.skin_tone != Color.TRANSPARENT: + _skin_base = loadout.skin_tone + _sync_skin_controls() + _mark_saved_status() loadout_changed.emit(loadout) _status_label.text = "Loadout loaded." # --------------------------------------------------------------------------- -# Close +# Navigation / Back # --------------------------------------------------------------------------- +func _on_back_pressed() -> void: + await save_and_exit() + + func _on_close_pressed() -> void: - closed.emit() + await save_and_exit() queue_free() diff --git a/scripts/avatar_customization_screen.gd b/scripts/avatar_customization_screen.gd index 4fd2aa5..93ecc39 100644 --- a/scripts/avatar_customization_screen.gd +++ b/scripts/avatar_customization_screen.gd @@ -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() diff --git a/scripts/chat_ui.gd b/scripts/chat_ui.gd index 14ad8c6..b95dd4e 100644 --- a/scripts/chat_ui.gd +++ b/scripts/chat_ui.gd @@ -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 diff --git a/scripts/controls_modal.gd b/scripts/controls_modal.gd new file mode 100644 index 0000000..f7c4175 --- /dev/null +++ b/scripts/controls_modal.gd @@ -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() diff --git a/scripts/discovery_ui.gd b/scripts/discovery_ui.gd index fff4a96..34319ca 100644 --- a/scripts/discovery_ui.gd +++ b/scripts/discovery_ui.gd @@ -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() diff --git a/scripts/editor/ui/in_game_editor.gd b/scripts/editor/ui/in_game_editor.gd index e600940..cceb9f1 100644 --- a/scripts/editor/ui/in_game_editor.gd +++ b/scripts/editor/ui/in_game_editor.gd @@ -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") diff --git a/scripts/friends_presence_ui.gd b/scripts/friends_presence_ui.gd index 8fe3dca..d9d7d73 100644 --- a/scripts/friends_presence_ui.gd +++ b/scripts/friends_presence_ui.gd @@ -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() diff --git a/scripts/game.gd b/scripts/game.gd index 51cd230..35eecc4 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -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() + 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: - _setup_remote_visual(node, 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 diff --git a/scripts/groups_ui.gd b/scripts/groups_ui.gd index 0e283ea..cd61be4 100644 --- a/scripts/groups_ui.gd +++ b/scripts/groups_ui.gd @@ -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 diff --git a/scripts/main_menu.gd b/scripts/main_menu.gd index 0269fb4..cd16234 100644 --- a/scripts/main_menu.gd +++ b/scripts/main_menu.gd @@ -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) diff --git a/scripts/marketplace_ui.gd b/scripts/marketplace_ui.gd index 13cc3b5..7c68f52 100644 --- a/scripts/marketplace_ui.gd +++ b/scripts/marketplace_ui.gd @@ -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() diff --git a/scripts/network_client.gd b/scripts/network_client.gd index adcce50..75a3de0 100644 --- a/scripts/network_client.gd +++ b/scripts/network_client.gd @@ -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 + diff --git a/scripts/pause_menu.gd b/scripts/pause_menu.gd new file mode 100644 index 0000000..4bfcb03 --- /dev/null +++ b/scripts/pause_menu.gd @@ -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() diff --git a/scripts/player_spawner.gd b/scripts/player_spawner.gd index 4cd47f3..19acd98 100644 --- a/scripts/player_spawner.gd +++ b/scripts/player_spawner.gd @@ -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"]) diff --git a/scripts/profile_ui.gd b/scripts/profile_ui.gd index dd55454..061530e 100644 --- a/scripts/profile_ui.gd +++ b/scripts/profile_ui.gd @@ -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 diff --git a/scripts/report_modal.gd b/scripts/report_modal.gd index af200b3..c84c360 100644 --- a/scripts/report_modal.gd +++ b/scripts/report_modal.gd @@ -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() diff --git a/scripts/selling_payout_ui.gd b/scripts/selling_payout_ui.gd index 1699935..f4de01f 100644 --- a/scripts/selling_payout_ui.gd +++ b/scripts/selling_payout_ui.gd @@ -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 = "" diff --git a/scripts/smoke_test_audio_manager.gd b/scripts/smoke_test_audio_manager.gd new file mode 100644 index 0000000..fde0f9b --- /dev/null +++ b/scripts/smoke_test_audio_manager.gd @@ -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) diff --git a/scripts/smoke_test_avatar_studio_ui.gd b/scripts/smoke_test_avatar_studio_ui.gd new file mode 100644 index 0000000..e70baa1 --- /dev/null +++ b/scripts/smoke_test_avatar_studio_ui.gd @@ -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) diff --git a/scripts/smoke_test_enter_world.gd b/scripts/smoke_test_enter_world.gd index f92cbee..3451eb4 100644 --- a/scripts/smoke_test_enter_world.gd +++ b/scripts/smoke_test_enter_world.gd @@ -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() diff --git a/scripts/social_api_client.gd b/scripts/social_api_client.gd index 5fe8e6c..a52deee 100644 --- a/scripts/social_api_client.gd +++ b/scripts/social_api_client.gd @@ -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) diff --git a/scripts/wallet_ui.gd b/scripts/wallet_ui.gd index c3c7636..af266eb 100644 --- a/scripts/wallet_ui.gd +++ b/scripts/wallet_ui.gd @@ -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 diff --git a/scripts/world_briefing_modal.gd b/scripts/world_briefing_modal.gd index a4537cb..2ad366e 100644 --- a/scripts/world_briefing_modal.gd +++ b/scripts/world_briefing_modal.gd @@ -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() diff --git a/scripts/world_list.gd b/scripts/world_list.gd index 35880a0..fe52252 100644 --- a/scripts/world_list.gd +++ b/scripts/world_list.gd @@ -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()