extends Node # ============================================================================ # AppearanceManager.gd - 玩家外观偏好管理器 # ============================================================================ # 统一管理玩家皮肤和头像偏好。账号资产以后端 profile 为准,本地只保留 # 当前会话内的注册/上传预览纹理,避免不同账号之间串用 user:// 资产。 # ============================================================================ const CONFIG_PATH: String = "user://appearance.cfg" const FALLBACK_SKIN_ID: String = "classic_whale" const DEFAULT_AVATAR_ID: String = "default" const CUSTOM_AVATAR_ID: String = "custom" const CUSTOM_SKIN_ID: String = "custom_skin" const CUSTOM_SKIN_HFRAMES: int = 8 const CUSTOM_SKIN_VFRAMES: int = 4 const CUSTOM_AVATAR_SIZE: int = 256 const REMOTE_SKIN_MAX_BYTES: int = 2 * 1024 * 1024 const REMOTE_SKIN_RETRY_DELAY: float = 3.0 const REMOTE_SKIN_MAX_RETRIES: int = 3 const REMOTE_SKIN_CACHE_DIR: String = "user://skin_cache" const AVATAR_CORNER_RADIUS_RATIO: float = 0.22 const DEFAULT_CHARACTER_TEXTURE_PATH: String = "res://assets/characters/player_pixel_spritesheet.png" const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd") const SKINS: Array[Dictionary] = [ { "id": "classic_whale", "name": "经典鲸鱼", "desc": "圆润轻快的鲸鱼居民角色", "texture": DEFAULT_CHARACTER_TEXTURE_PATH, "hframes": 4, "vframes": 4, "modulate": Color(1.0, 1.0, 1.0, 1.0), "accent": Color(0.392157, 0.717647, 0.94902, 1.0), }, { "id": "human_whale_directional_v2_8x4", "name": "海风行者8帧", "desc": "四方向分别生成的8帧高清人类角色", "texture": "res://assets/characters/skins/human_whale_directional_v2_8x4_spritesheet.png", "hframes": 8, "vframes": 4, "modulate": Color(1.0, 1.0, 1.0, 1.0), "accent": Color(0.109804, 0.309804, 0.568627, 1.0), }, { "id": "girl_sailor_turnaround_v2_8x4", "name": "海风少女8帧", "desc": "四方向分别生成的8帧高清女生角色", "texture": "res://assets/characters/skins/girl_sailor_turnaround_v2_8x4_spritesheet.png", "hframes": 8, "vframes": 4, "modulate": Color(1.0, 1.0, 1.0, 1.0), "accent": Color(0.109804, 0.309804, 0.568627, 1.0), }, { "id": "panda_hero_8x4", "name": "熊猫侠", "desc": "玩家上传参考图生成的四方向8帧角色", "texture": "res://assets/characters/skins/panda_hero_8x4_spritesheet.png", "hframes": 8, "vframes": 4, "modulate": Color(1.0, 1.0, 1.0, 1.0), "accent": Color(0.12, 0.12, 0.12, 1.0), }, { "id": "ordinary_man_male_8x4", "name": "普通人(男)", "desc": "四方向8帧的男性日常角色", "texture": "res://assets/characters/skins/ordinary_man_male_8x4_spritesheet.png", "hframes": 8, "vframes": 4, "modulate": Color(1.0, 1.0, 1.0, 1.0), "accent": Color(0.109804, 0.309804, 0.568627, 1.0), }, ] const DEFAULT_AVATAR: Dictionary = { "id": DEFAULT_AVATAR_ID, "name": "默认头像", "label": "鲸", "color": Color(0.392157, 0.717647, 0.94902, 1.0), "accent": Color(0.172549, 0.482353, 0.776471, 1.0), } var selectedSkinId: String = FALLBACK_SKIN_ID var selectedAvatarId: String = DEFAULT_AVATAR_ID var ownedSkinIds: Dictionary = {} var _customAvatarTexture: Texture2D var _customAvatarActive: bool = false var _customSkinTexture: Texture2D var _customSkinActive: bool = false var _accountSkinAssets: Dictionary = {} var _accountSkinTextures: Dictionary = {} var _remoteSkinTextures: Dictionary = {} var _remoteSkinRequests: Dictionary = {} var _remoteSkinRetryCounts: Dictionary = {} var _accountAvatarUrl: String = "" var _accountAvatarTexture: Texture2D var _profileSyncSuppressed: bool = false func _ready() -> void: _load() _connect_auth_events() _emit_profile_changed() func get_skin_catalog(includeLocked: bool = true) -> Array[Dictionary]: var result: Array[Dictionary] = [] for skin in SKINS: var nextSkin := skin.duplicate(true) var skinId := str(nextSkin.get("id", "")).strip_edges() var owned := is_skin_owned(skinId) nextSkin["owned"] = owned nextSkin["locked"] = not owned if includeLocked or owned: result.append(nextSkin) for skinId in _accountSkinAssets.keys(): var accountSkin: Dictionary = (_accountSkinAssets[skinId] as Dictionary).duplicate(true) accountSkin["owned"] = true accountSkin["locked"] = false result.append(accountSkin) return result func get_owned_skin_catalog() -> Array[Dictionary]: return get_skin_catalog(false) func get_registration_skin_catalog() -> Array[Dictionary]: var result: Array[Dictionary] = [] for skin in SKINS: var nextSkin := skin.duplicate(true) nextSkin["owned"] = true nextSkin["locked"] = false result.append(nextSkin) for skinId in _accountSkinAssets.keys(): var accountSkin: Dictionary = (_accountSkinAssets[skinId] as Dictionary).duplicate(true) accountSkin["owned"] = true accountSkin["locked"] = false result.append(accountSkin) return result func get_avatar_catalog() -> Array[Dictionary]: return [] func get_selected_skin_id() -> String: return selectedSkinId func get_selected_avatar_id() -> String: return selectedAvatarId func get_selected_skin() -> Dictionary: return get_skin(selectedSkinId) func get_selected_avatar() -> Dictionary: return get_avatar(selectedAvatarId) func get_skin(skinId: String) -> Dictionary: var normalizedId := skinId.strip_edges() if normalizedId == CUSTOM_SKIN_ID and has_custom_skin(): return _custom_skin_entry() if _accountSkinAssets.has(normalizedId): return (_accountSkinAssets[normalizedId] as Dictionary).duplicate(true) return _find_entry(SKINS, skinId, FALLBACK_SKIN_ID) func get_avatar(avatarId: String) -> Dictionary: var normalizedId := avatarId.strip_edges() if normalizedId == CUSTOM_AVATAR_ID and has_custom_avatar(): var customAvatar := DEFAULT_AVATAR.duplicate(true) customAvatar["id"] = CUSTOM_AVATAR_ID customAvatar["name"] = "账号头像" customAvatar["label"] = "" customAvatar["color"] = Color(1, 1, 1, 1) return customAvatar if normalizedId == CUSTOM_AVATAR_ID and (_accountAvatarTexture != null or not _accountAvatarUrl.is_empty()): var accountAvatar := DEFAULT_AVATAR.duplicate(true) accountAvatar["id"] = CUSTOM_AVATAR_ID accountAvatar["name"] = "账号头像" accountAvatar["label"] = "" accountAvatar["image_path"] = _accountAvatarUrl accountAvatar["color"] = Color(1, 1, 1, 1) return accountAvatar return DEFAULT_AVATAR.duplicate(true) func set_owned_skin_ids(skinIds: Array) -> void: ownedSkinIds.clear() for skinIdVariant in skinIds: var normalizedId := str(skinIdVariant).strip_edges() if not normalizedId.is_empty(): ownedSkinIds[normalizedId] = true if not is_skin_owned(selectedSkinId): selectedSkinId = _first_owned_skin_id() _save() _emit_skin_changed() _emit_profile_changed() func _first_owned_skin_id() -> String: for skinId in ownedSkinIds.keys(): var normalizedId := str(skinId).strip_edges() if not normalizedId.is_empty(): return normalizedId return FALLBACK_SKIN_ID func grant_owned_skin(skinId: String) -> void: var normalizedId := skinId.strip_edges() if normalizedId.is_empty(): return ownedSkinIds[normalizedId] = true _emit_profile_changed() func is_skin_owned(skinId: String) -> bool: var normalizedId := skinId.strip_edges() if _accountSkinAssets.has(normalizedId): return true if normalizedId == CUSTOM_SKIN_ID: return has_custom_skin() return ownedSkinIds.has(normalizedId) func set_selected_skin(skinId: String, allowLocked: bool = false) -> bool: var normalizedId := skinId.strip_edges() if normalizedId == CUSTOM_SKIN_ID and not has_custom_skin(): return false if normalizedId.is_empty() or (normalizedId != CUSTOM_SKIN_ID and not _has_entry(SKINS, normalizedId) and not _accountSkinAssets.has(normalizedId)): return false if not allowLocked and not is_skin_owned(normalizedId): return false if normalizedId == selectedSkinId: return true selectedSkinId = normalizedId _save() _emit_skin_changed() _emit_profile_changed() if not _profileSyncSuppressed: _sync_account_skin_profile() return true func apply_account_profile(profile: Dictionary) -> void: _profileSyncSuppressed = true _clear_session_custom_avatar() _clear_session_custom_skin() var skinId := str(profile.get("skin_id", "")).strip_edges() _accountAvatarUrl = _absolute_backend_url(str(profile.get("avatar_url", "")).strip_edges()) _accountAvatarTexture = _texture_from_base64(str(profile.get("avatar_base64", "")).strip_edges()) var ownedSkinsVariant: Variant = profile.get("owned_skins", []) if ownedSkinsVariant is Array: apply_account_skin_assets(ownedSkinsVariant as Array) var ownedVariant: Variant = profile.get("owned_skin_ids", []) if ownedVariant is Array: set_owned_skin_ids(ownedVariant) if not skinId.is_empty(): grant_owned_skin(skinId) set_selected_skin(skinId, true) else: var fallbackSkinId := _first_owned_skin_id() if selectedSkinId != fallbackSkinId: selectedSkinId = fallbackSkinId _save() _emit_skin_changed() _emit_profile_changed() _profileSyncSuppressed = false if _accountAvatarTexture != null or not _accountAvatarUrl.is_empty(): selectedAvatarId = CUSTOM_AVATAR_ID _save() _emit_avatar_changed() _emit_profile_changed() elif selectedAvatarId != DEFAULT_AVATAR_ID: selectedAvatarId = DEFAULT_AVATAR_ID _save() _emit_avatar_changed() _emit_profile_changed() func clear_account_profile() -> void: _accountSkinAssets.clear() _accountSkinTextures.clear() _accountAvatarUrl = "" _accountAvatarTexture = null _clear_session_custom_avatar() _clear_session_custom_skin() selectedSkinId = FALLBACK_SKIN_ID selectedAvatarId = DEFAULT_AVATAR_ID ownedSkinIds.clear() _save() _emit_skin_changed() _emit_avatar_changed() _emit_profile_changed() func apply_account_skin_assets(skins: Array) -> void: _accountSkinAssets.clear() _accountSkinTextures.clear() for skinVariant in skins: if not (skinVariant is Dictionary): continue var skin := _normalize_account_skin_asset(skinVariant as Dictionary) var skinId := str(skin.get("id", "")).strip_edges() if skinId.is_empty(): continue _accountSkinAssets[skinId] = skin ownedSkinIds[skinId] = true if skin.has("texture_base64"): var texture := _texture_from_base64(str(skin.get("texture_base64", ""))) if texture != null: _accountSkinTextures[skinId] = texture _emit_profile_changed() func apply_account_avatar_base64(avatarUrl: String, avatarBase64: String) -> void: _accountAvatarUrl = _absolute_backend_url(avatarUrl.strip_edges()) _customAvatarTexture = null _customAvatarActive = false _accountAvatarTexture = _texture_from_base64(avatarBase64) if _accountAvatarTexture != null or not _accountAvatarUrl.is_empty(): selectedAvatarId = CUSTOM_AVATAR_ID else: selectedAvatarId = DEFAULT_AVATAR_ID _save() _emit_avatar_changed() _emit_profile_changed() func set_selected_avatar(avatarId: String) -> bool: var normalizedId := avatarId.strip_edges() if normalizedId == CUSTOM_AVATAR_ID and not has_custom_avatar(): return false if normalizedId != DEFAULT_AVATAR_ID and normalizedId != CUSTOM_AVATAR_ID: return false if normalizedId == selectedAvatarId: return true selectedAvatarId = normalizedId _save() _emit_avatar_changed() _emit_profile_changed() return true func set_custom_avatar_from_file(sourcePath: String) -> bool: var image := Image.load_from_file(sourcePath) if image == null: push_warning("AppearanceManager: 头像图片读取失败: %s" % sourcePath) return false var cropped := _create_square_avatar_image(image) _apply_rounded_rect_alpha(cropped) _customAvatarTexture = ImageTexture.create_from_image(cropped) _customAvatarActive = true selectedAvatarId = CUSTOM_AVATAR_ID _save() _emit_avatar_changed() _emit_profile_changed() return true func set_custom_skin_from_file(sourcePath: String) -> bool: var image := Image.load_from_file(sourcePath) if image == null: push_warning("AppearanceManager: 角色皮肤图片读取失败: %s" % sourcePath) return false if image.get_width() <= 0 or image.get_height() <= 0: push_warning("AppearanceManager: 角色皮肤图片尺寸无效: %s" % sourcePath) return false if image.get_width() % CUSTOM_SKIN_HFRAMES != 0 or image.get_height() % CUSTOM_SKIN_VFRAMES != 0: push_warning("AppearanceManager: 自定义角色皮肤需要 %dx%d 网格: %s" % [CUSTOM_SKIN_HFRAMES, CUSTOM_SKIN_VFRAMES, sourcePath]) return false if image.get_format() != Image.FORMAT_RGBA8: image.convert(Image.FORMAT_RGBA8) _customSkinTexture = ImageTexture.create_from_image(image) _customSkinActive = true grant_owned_skin(CUSTOM_SKIN_ID) selectedSkinId = CUSTOM_SKIN_ID _save() _emit_skin_changed() _emit_profile_changed() return true func has_custom_avatar() -> bool: return (_customAvatarActive and _customAvatarTexture != null) or _accountAvatarTexture != null or not _accountAvatarUrl.is_empty() func has_custom_skin() -> bool: return _customSkinActive and _customSkinTexture != null func get_custom_avatar_texture() -> Texture2D: if has_custom_avatar(): return _customAvatarTexture return _accountAvatarTexture func get_custom_skin_texture() -> Texture2D: return _customSkinTexture func get_account_skin_texture(skinId: String) -> Texture2D: var normalizedId := skinId.strip_edges() if _accountSkinTextures.has(normalizedId): return _accountSkinTextures[normalizedId] as Texture2D var skin: Dictionary = _accountSkinAssets.get(normalizedId, {}) var textureBase64 := str(skin.get("texture_base64", "")).strip_edges() if not textureBase64.is_empty(): var texture := _texture_from_base64(textureBase64) if texture != null: _accountSkinTextures[normalizedId] = texture return texture return null func is_remote_skin_ready(skinId: String, skinAsset: Dictionary) -> bool: var normalizedId := skinId.strip_edges() if normalizedId.is_empty(): return true if _has_entry(SKINS, normalizedId): return true if _accountSkinTextures.has(normalizedId) or _remoteSkinTextures.has(normalizedId): return true return false func request_remote_skin(skinId: String, skinAsset: Dictionary) -> void: var normalizedId := skinId.strip_edges() if normalizedId.is_empty() or is_remote_skin_ready(normalizedId, skinAsset): _emit_remote_skin_event(EventNames.REMOTE_SKIN_READY, normalizedId, skinAsset) return if _remoteSkinRequests.has(normalizedId): return var normalizedAsset := _normalize_account_skin_asset(skinAsset) var textureUrl := str(normalizedAsset.get("texture_url", "")).strip_edges() if textureUrl.is_empty(): _emit_remote_skin_event(EventNames.REMOTE_SKIN_FAILED, normalizedId, normalizedAsset) return var cachedTexture := _load_remote_skin_cache(normalizedId, normalizedAsset) if cachedTexture != null: _remoteSkinTextures[normalizedId] = cachedTexture _emit_remote_skin_event(EventNames.REMOTE_SKIN_READY, normalizedId, normalizedAsset) return var request := HTTPRequest.new() request.timeout = 12.0 request.body_size_limit = REMOTE_SKIN_MAX_BYTES add_child(request) _remoteSkinRequests[normalizedId] = request request.request_completed.connect(_on_remote_skin_request_completed.bind(normalizedId, normalizedAsset, request), CONNECT_ONE_SHOT) var err := request.request(textureUrl) if err != OK: _remoteSkinRequests.erase(normalizedId) request.queue_free() _handle_remote_skin_failure(normalizedId, normalizedAsset) func get_remote_skin_texture(skinId: String) -> Texture2D: var normalizedId := skinId.strip_edges() if _accountSkinTextures.has(normalizedId): return _accountSkinTextures[normalizedId] as Texture2D if _remoteSkinTextures.has(normalizedId): return _remoteSkinTextures[normalizedId] as Texture2D return null func apply_skin_to_sprite(sprite: Sprite2D, skinId: String = "") -> void: if sprite == null: return var skin := get_skin(selectedSkinId if skinId.strip_edges().is_empty() else skinId) var texture: Texture2D var appliedSkinId := str(skin.get("id", "")) if _accountSkinAssets.has(appliedSkinId): texture = get_account_skin_texture(appliedSkinId) elif appliedSkinId == CUSTOM_SKIN_ID: texture = get_custom_skin_texture() else: var texturePath := str(skin.get("texture", DEFAULT_CHARACTER_TEXTURE_PATH)) texture = load(texturePath) as Texture2D if texture != null: sprite.texture = texture sprite.modulate = Color.WHITE sprite.hframes = int(skin.get("hframes", 4)) sprite.vframes = int(skin.get("vframes", 4)) func apply_skin_asset_to_sprite(sprite: Sprite2D, skinId: String, skinAsset: Dictionary) -> void: if sprite == null: return var normalizedAsset := _normalize_account_skin_asset(skinAsset) var assetSkinId := str(normalizedAsset.get("id", "")).strip_edges() if assetSkinId.is_empty() or (not skinId.strip_edges().is_empty() and assetSkinId != skinId.strip_edges()): apply_skin_to_sprite(sprite, skinId) return var texture := get_remote_skin_texture(assetSkinId) if texture == null: texture = _texture_from_base64(str(normalizedAsset.get("texture_base64", ""))) if texture == null: apply_skin_to_sprite(sprite, skinId) return sprite.texture = texture sprite.modulate = Color.WHITE sprite.hframes = int(normalizedAsset.get("hframes", CUSTOM_SKIN_HFRAMES)) sprite.vframes = int(normalizedAsset.get("vframes", CUSTOM_SKIN_VFRAMES)) func _on_remote_skin_request_completed( result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray, skinId: String, skinAsset: Dictionary, request: HTTPRequest ) -> void: _remoteSkinRequests.erase(skinId) if is_instance_valid(request): request.queue_free() if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300: _handle_remote_skin_failure(skinId, skinAsset) return var texture := _texture_from_png_bytes(body, skinAsset) if texture == null: _handle_remote_skin_failure(skinId, skinAsset) return _remoteSkinTextures[skinId] = texture _remoteSkinRetryCounts.erase(skinId) _save_remote_skin_cache(skinId, body) _emit_remote_skin_event(EventNames.REMOTE_SKIN_READY, skinId, skinAsset) func _handle_remote_skin_failure(skinId: String, skinAsset: Dictionary) -> void: var retryCount := int(_remoteSkinRetryCounts.get(skinId, 0)) + 1 _remoteSkinRetryCounts[skinId] = retryCount if retryCount < REMOTE_SKIN_MAX_RETRIES: _retry_remote_skin(skinId, skinAsset) return _emit_remote_skin_event(EventNames.REMOTE_SKIN_FAILED, skinId, skinAsset) func _retry_remote_skin(skinId: String, skinAsset: Dictionary) -> void: await get_tree().create_timer(REMOTE_SKIN_RETRY_DELAY).timeout if not is_remote_skin_ready(skinId, skinAsset): request_remote_skin(skinId, skinAsset) func _texture_from_png_bytes(bytes: PackedByteArray, skinAsset: Dictionary) -> Texture2D: if bytes.is_empty() or bytes.size() > REMOTE_SKIN_MAX_BYTES: return null var image := Image.new() if image.load_png_from_buffer(bytes) != OK: return null var hframes := maxi(1, int(skinAsset.get("hframes", CUSTOM_SKIN_HFRAMES))) var vframes := maxi(1, int(skinAsset.get("vframes", CUSTOM_SKIN_VFRAMES))) if image.get_width() % hframes != 0 or image.get_height() % vframes != 0: return null return ImageTexture.create_from_image(image) func _remote_skin_cache_path(skinId: String) -> String: return "%s/%s.png" % [REMOTE_SKIN_CACHE_DIR, skinId.sha256_text()] func _load_remote_skin_cache(skinId: String, skinAsset: Dictionary) -> Texture2D: var path := _remote_skin_cache_path(skinId) if not FileAccess.file_exists(path): return null var file := FileAccess.open(path, FileAccess.READ) if file == null: return null var texture := _texture_from_png_bytes(file.get_buffer(file.get_length()), skinAsset) if texture == null: DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) return texture func _save_remote_skin_cache(skinId: String, bytes: PackedByteArray) -> void: DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(REMOTE_SKIN_CACHE_DIR)) var file := FileAccess.open(_remote_skin_cache_path(skinId), FileAccess.WRITE) if file != null: file.store_buffer(bytes) func _emit_remote_skin_event(eventName: String, skinId: String, skinAsset: Dictionary) -> void: var eventSystem := get_node_or_null("/root/EventSystem") if eventSystem != null: eventSystem.call_deferred("emit_event", eventName, { "skin_id": skinId, "skin_asset": skinAsset, }) func apply_avatar_to_panel(panel: PanelContainer, label: Label, avatarId: String = "", fallbackText: String = "") -> void: if panel == null: return var avatar := get_avatar(selectedAvatarId if avatarId.strip_edges().is_empty() else avatarId) panel.add_theme_stylebox_override("panel", create_avatar_style(avatar, _avatar_radius_for(panel))) panel.clip_contents = true var textureRect := _get_or_create_avatar_texture_rect(panel) var avatarTexture := get_custom_avatar_texture() if str(avatar.get("id", "")) == CUSTOM_AVATAR_ID else null if textureRect != null: textureRect.texture = avatarTexture textureRect.visible = avatarTexture != null textureRect.custom_minimum_size = panel.custom_minimum_size if label != null: label.visible = avatarTexture == null var avatarLabel := str(avatar.get("label", "")).strip_edges() if avatarLabel.is_empty(): avatarLabel = fallbackText.strip_edges().substr(0, 1) if avatarLabel.is_empty(): avatarLabel = "鲸" label.text = avatarLabel label.add_theme_color_override("font_color", Color.WHITE) func create_avatar_style(avatar: Dictionary, radius: int = 12, disabled: bool = false) -> StyleBoxFlat: var style := StyleBoxFlat.new() var baseColor := avatar.get("color", Color(0.392157, 0.717647, 0.94902, 1.0)) as Color style.bg_color = baseColor if not disabled else Color(0.678, 0.725, 0.773, 1.0) style.corner_radius_top_left = radius style.corner_radius_top_right = radius style.corner_radius_bottom_left = radius style.corner_radius_bottom_right = radius style.border_width_left = 1 style.border_width_top = 1 style.border_width_right = 1 style.border_width_bottom = 1 style.border_color = Color(1, 1, 1, 0.34) return style func create_fallback_avatar_style(username: String, radius: int = 12, disabled: bool = false) -> StyleBoxFlat: var colors: Array[Color] = [ Color(0.392157, 0.717647, 0.94902, 1.0), Color(0.843137, 0.65098, 0.47451, 1.0), Color(0.364706, 0.760784, 0.670588, 1.0), Color(0.584314, 0.592157, 0.894118, 1.0), Color(0.929412, 0.564706, 0.462745, 1.0), ] var index: int = abs(hash(username.strip_edges())) % colors.size() var avatar: Dictionary = { "color": colors[index], } return create_avatar_style(avatar, radius, disabled) func get_avatar_label(avatarId: String = "") -> String: var avatar := get_avatar(selectedAvatarId if avatarId.strip_edges().is_empty() else avatarId) return str(avatar.get("label", "鲸")) func reset_to_default_avatar() -> bool: if selectedAvatarId == DEFAULT_AVATAR_ID: return true selectedAvatarId = DEFAULT_AVATAR_ID _customAvatarTexture = null _customAvatarActive = false _accountAvatarTexture = null _accountAvatarUrl = "" _save() _emit_avatar_changed() _emit_profile_changed() return true func _find_entry(entries: Array[Dictionary], entryId: String, fallbackId: String) -> Dictionary: for entry in entries: if str(entry.get("id", "")) == entryId: return entry.duplicate(true) for entry in entries: if str(entry.get("id", "")) == fallbackId: return entry.duplicate(true) return entries[0].duplicate(true) if not entries.is_empty() else {} func _has_entry(entries: Array[Dictionary], entryId: String) -> bool: for entry in entries: if str(entry.get("id", "")) == entryId: return true return false func _avatar_radius_for(panel: PanelContainer) -> int: var minSize: Vector2 = panel.custom_minimum_size var smallestSide: float = minf(minSize.x, minSize.y) if smallestSide <= 0.0: return 12 return max(8, int(round(smallestSide * AVATAR_CORNER_RADIUS_RATIO))) func _load() -> void: var config := ConfigFile.new() if config.load(CONFIG_PATH) != OK: selectedSkinId = FALLBACK_SKIN_ID selectedAvatarId = DEFAULT_AVATAR_ID return var skinId := str(config.get_value("appearance", "selected_skin_id", FALLBACK_SKIN_ID)).strip_edges() var avatarId := str(config.get_value("appearance", "selected_avatar_id", DEFAULT_AVATAR_ID)).strip_edges() selectedSkinId = skinId if _has_entry(SKINS, skinId) else FALLBACK_SKIN_ID selectedAvatarId = DEFAULT_AVATAR_ID if avatarId == CUSTOM_AVATAR_ID else avatarId if selectedAvatarId != DEFAULT_AVATAR_ID: selectedAvatarId = DEFAULT_AVATAR_ID func _save() -> void: var config := ConfigFile.new() config.set_value("appearance", "selected_skin_id", selectedSkinId) config.set_value("appearance", "selected_avatar_id", selectedAvatarId) var err := config.save(CONFIG_PATH) if err != OK: push_warning("AppearanceManager: 保存外观配置失败: %s" % error_string(err)) func _emit_skin_changed() -> void: var eventSystem := get_node_or_null("/root/EventSystem") if eventSystem != null: eventSystem.call("emit_event", EventNames.APPEARANCE_SKIN_CHANGED, { "skin_id": selectedSkinId, "skin": get_selected_skin(), }) func _emit_avatar_changed() -> void: var eventSystem := get_node_or_null("/root/EventSystem") if eventSystem != null: eventSystem.call("emit_event", EventNames.APPEARANCE_AVATAR_CHANGED, { "avatar_id": selectedAvatarId, "avatar": get_selected_avatar(), }) func _emit_profile_changed() -> void: var eventSystem := get_node_or_null("/root/EventSystem") if eventSystem != null: eventSystem.call("emit_event", EventNames.APPEARANCE_PROFILE_CHANGED, get_profile_payload()) func _sync_account_skin_profile() -> void: var authManager := get_node_or_null("/root/AuthManager") if authManager == null or not authManager.has_method("is_authenticated"): return if not bool(authManager.call("is_authenticated")): return if authManager.has_method("update_profile"): authManager.call("update_profile", { "skin_id": selectedSkinId, }) func _connect_auth_events() -> void: var eventSystem := get_node_or_null("/root/EventSystem") if eventSystem == null: return eventSystem.call("connect_event", EventNames.AUTH_LOGOUT, _on_auth_logout, self) func _on_auth_logout(_data: Variant = null) -> void: clear_account_profile() func get_profile_payload() -> Dictionary: return { "skin_id": selectedSkinId, "avatar_id": selectedAvatarId, "avatar_url": _accountAvatarUrl, "skin": get_selected_skin(), "avatar": get_selected_avatar(), } func _create_square_avatar_image(image: Image) -> Image: var side: int = mini(image.get_width(), image.get_height()) var cropX: int = int((image.get_width() - side) * 0.5) var cropY: int = int((image.get_height() - side) * 0.5) var cropped := image.get_region(Rect2i(cropX, cropY, side, side)) cropped.resize(CUSTOM_AVATAR_SIZE, CUSTOM_AVATAR_SIZE, Image.INTERPOLATE_LANCZOS) return cropped func _custom_skin_entry() -> Dictionary: return { "id": CUSTOM_SKIN_ID, "name": "待保存角色", "desc": "注册页本次上传的8x4角色皮肤预览", "texture": "", "hframes": CUSTOM_SKIN_HFRAMES, "vframes": CUSTOM_SKIN_VFRAMES, "modulate": Color.WHITE, "accent": Color(0.109804, 0.309804, 0.568627, 1.0), "session_preview": true, } func _get_or_create_avatar_texture_rect(panel: PanelContainer) -> TextureRect: var existing := panel.get_node_or_null("AvatarTexture") as TextureRect if existing != null: return existing var textureRect := TextureRect.new() textureRect.name = "AvatarTexture" textureRect.mouse_filter = Control.MOUSE_FILTER_IGNORE textureRect.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR textureRect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE textureRect.stretch_mode = TextureRect.STRETCH_SCALE textureRect.custom_minimum_size = panel.custom_minimum_size textureRect.set_anchors_preset(Control.PRESET_FULL_RECT) panel.add_child(textureRect) return textureRect func _normalize_account_skin_asset(skin: Dictionary) -> Dictionary: var normalized := skin.duplicate(true) var skinId := str(normalized.get("id", normalized.get("skin_id", ""))).strip_edges() if skinId.is_empty(): return {} normalized["id"] = skinId normalized["name"] = str(normalized.get("name", "账号角色")) normalized["desc"] = str(normalized.get("desc", "账号自定义角色皮肤")) normalized["texture"] = _absolute_backend_url(str(normalized.get("texture_url", ""))) normalized["texture_url"] = normalized["texture"] normalized["hframes"] = int(normalized.get("hframes", CUSTOM_SKIN_HFRAMES)) normalized["vframes"] = int(normalized.get("vframes", CUSTOM_SKIN_VFRAMES)) normalized["modulate"] = Color.WHITE normalized["accent"] = Color(0.109804, 0.309804, 0.568627, 1.0) normalized["account_asset"] = true return normalized func _texture_from_base64(base64Text: String) -> Texture2D: var bytes := Marshalls.base64_to_raw(base64Text.strip_edges()) if bytes.is_empty(): return null var image := Image.new() var err := image.load_png_from_buffer(bytes) if err != OK: return null return ImageTexture.create_from_image(image) func _absolute_backend_url(path: String) -> String: var normalized := path.strip_edges() if normalized.is_empty() or normalized.begins_with("http://") or normalized.begins_with("https://"): return normalized if normalized.begins_with("/"): return "%s%s" % [NetworkConfig.get_api_base_url(), normalized] return normalized func _clear_session_custom_skin() -> void: if selectedSkinId == CUSTOM_SKIN_ID: selectedSkinId = _first_owned_skin_id() _customSkinTexture = null _customSkinActive = false ownedSkinIds.erase(CUSTOM_SKIN_ID) func _clear_session_custom_avatar() -> void: _customAvatarTexture = null _customAvatarActive = false func _apply_rounded_rect_alpha(image: Image) -> void: var width := image.get_width() var height := image.get_height() if width <= 0 or height <= 0: return if image.get_format() != Image.FORMAT_RGBA8: image.convert(Image.FORMAT_RGBA8) var radius: float = maxf(1.0, minf(float(width), float(height)) * AVATAR_CORNER_RADIUS_RATIO) var feather: float = 1.5 var maxX: float = float(width - 1) var maxY: float = float(height - 1) for x in range(width): for y in range(height): var px: float = float(x) var py: float = float(y) var cornerCenter: Vector2 = Vector2(px, py) var inCorner: bool = false if px < radius and py < radius: cornerCenter = Vector2(radius, radius) inCorner = true elif px > maxX - radius and py < radius: cornerCenter = Vector2(maxX - radius, radius) inCorner = true elif px < radius and py > maxY - radius: cornerCenter = Vector2(radius, maxY - radius) inCorner = true elif px > maxX - radius and py > maxY - radius: cornerCenter = Vector2(maxX - radius, maxY - radius) inCorner = true if inCorner: var color: Color = image.get_pixel(x, y) var distance: float = Vector2(px, py).distance_to(cornerCenter) if distance > radius + feather: color.a = 0.0 image.set_pixel(x, y, color) elif distance > radius: var alphaFactor: float = clampf(1.0 - ((distance - radius) / feather), 0.0, 1.0) color.a *= alphaFactor image.set_pixel(x, y, color)