forked from xiangwang25/whale-town-front-v2
857 lines
30 KiB
GDScript
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)
|