Files
whale-town-front-v2/_Core/managers/AppearanceManager.gd

857 lines
30 KiB
GDScript

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)