Initial WhaleTown V2 frontend
This commit is contained in:
856
_Core/managers/AppearanceManager.gd
Normal file
856
_Core/managers/AppearanceManager.gd
Normal file
@@ -0,0 +1,856 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user