Initial WhaleTown V2 frontend

This commit is contained in:
2026-07-19 22:42:20 +08:00
commit 435c578dde
1421 changed files with 54486 additions and 0 deletions

130
_Core/managers/ApiClient.gd Normal file
View File

@@ -0,0 +1,130 @@
extends Node
# ============================================================================
# ApiClient.gd - 后端 JSON API 客户端
# ============================================================================
# 统一处理 WhaleTown 后端 HTTP JSON 请求、认证头和响应信封解析。
# ============================================================================
signal request_failed(endpoint: String, message: String)
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const REQUEST_TIMEOUT: float = 12.0
var _activeRequests: Array[HTTPRequest] = []
func _exit_tree() -> void:
for request in _activeRequests:
if is_instance_valid(request):
request.cancel_request()
request.queue_free()
_activeRequests.clear()
func get_json(endpoint: String, callback: Callable, authenticated: bool = true) -> void:
request_json(endpoint, {}, callback, HTTPClient.METHOD_GET, authenticated)
func post_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_POST, authenticated)
func patch_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_PATCH, authenticated)
func put_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_PUT, authenticated)
func request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_GET, authenticated: bool = true) -> void:
var request := HTTPRequest.new()
request.timeout = REQUEST_TIMEOUT
add_child(request)
_activeRequests.append(request)
request.request_completed.connect(func(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
_activeRequests.erase(request)
request.queue_free()
_handle_response(endpoint, result, responseCode, body, callback)
)
var url := "%s%s" % [NetworkConfig.get_api_base_url(), endpoint]
var body := "" if method == HTTPClient.METHOD_GET else JSON.stringify(payload)
var err := request.request(url, _headers(authenticated), method, body)
if err != OK:
_activeRequests.erase(request)
request.queue_free()
var message := "网络请求发送失败: %s" % error_string(err)
request_failed.emit(endpoint, message)
callback.call(false, {}, {"message": message})
func _headers(authenticated: bool) -> PackedStringArray:
var headers := PackedStringArray(["Content-Type: application/json"])
if authenticated:
var authManager := get_node_or_null("/root/AuthManager")
var accessToken := str(authManager.call("get_access_token")).strip_edges() if authManager != null and authManager.has_method("get_access_token") else ""
if not accessToken.is_empty():
headers.append("Authorization: Bearer %s" % accessToken)
return headers
func _handle_response(endpoint: String, result: int, responseCode: int, body: PackedByteArray, callback: Callable) -> void:
if result != HTTPRequest.RESULT_SUCCESS:
var message := "网络请求失败: %s" % _http_result_to_string(result)
request_failed.emit(endpoint, message)
callback.call(false, {}, {"message": message})
return
var bodyText := body.get_string_from_utf8()
var json := JSON.new()
if json.parse(bodyText) != OK:
var parseMessage := "服务器响应解析失败"
request_failed.emit(endpoint, parseMessage)
callback.call(false, {}, {"message": parseMessage, "response_code": responseCode})
return
var payloadVariant: Variant = json.data
if not (payloadVariant is Dictionary):
var formatMessage := "服务器响应格式错误"
request_failed.emit(endpoint, formatMessage)
callback.call(false, {}, {"message": formatMessage, "response_code": responseCode})
return
var response: Dictionary = payloadVariant
var ok := responseCode >= 200 and responseCode < 300 and bool(response.get("success", true))
if ok:
callback.call(true, response, {})
return
var errorInfo := {
"message": str(response.get("message", "请求失败")),
"response_code": responseCode,
"error_code": str(response.get("error_code", ""))
}
request_failed.emit(endpoint, str(errorInfo.get("message", "请求失败")))
callback.call(false, response, errorInfo)
func _http_result_to_string(result: int) -> String:
match result:
HTTPRequest.RESULT_CHUNKED_BODY_SIZE_MISMATCH:
return "响应分块大小不匹配"
HTTPRequest.RESULT_CANT_CONNECT:
return "无法连接服务器"
HTTPRequest.RESULT_CANT_RESOLVE:
return "无法解析服务器地址"
HTTPRequest.RESULT_CONNECTION_ERROR:
return "连接中断"
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
return "TLS握手失败"
HTTPRequest.RESULT_NO_RESPONSE:
return "服务器无响应"
HTTPRequest.RESULT_BODY_SIZE_LIMIT_EXCEEDED:
return "响应内容过大"
HTTPRequest.RESULT_REQUEST_FAILED:
return "请求失败"
HTTPRequest.RESULT_DOWNLOAD_FILE_CANT_OPEN:
return "下载文件无法打开"
HTTPRequest.RESULT_DOWNLOAD_FILE_WRITE_ERROR:
return "下载文件写入失败"
HTTPRequest.RESULT_REDIRECT_LIMIT_REACHED:
return "重定向次数过多"
HTTPRequest.RESULT_TIMEOUT:
return "请求超时"
_:
return "未知错误 %s" % result

View File

@@ -0,0 +1 @@
uid://dmpcxgvfo7uyo

View 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)

View File

@@ -0,0 +1 @@
uid://rsacygh811e5

View File

@@ -0,0 +1,515 @@
extends Node
# ============================================================================
# AuthManager.gd - V2 用户认证管理器
# ============================================================================
# 负责 V2 的正式多用户登录、注册、token 缓存和登出。
# 聊天系统通过这里获取当前用户 access_token不再在正常游戏流程中共用默认账号。
# ============================================================================
signal auth_state_changed(is_authenticated: bool, user: Dictionary)
signal login_succeeded(user: Dictionary)
signal login_failed(message: String)
signal register_succeeded(user: Dictionary)
signal register_failed(message: String)
signal email_verification_sent(email: String)
signal email_verification_failed(message: String)
signal profile_update_succeeded(profile: Dictionary)
signal profile_update_failed(message: String)
signal logout_completed()
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const DEFAULT_AUTH_CONFIG_PATH: String = "user://auth.cfg"
const REQUEST_TIMEOUT: float = 12.0
var _access_token: String = ""
var _refresh_token: String = ""
var _current_user: Dictionary = {}
var _current_profile: Dictionary = {}
var _active_requests: Array[HTTPRequest] = []
var _session_generation: int = 0
var _account_generation: int = 0
var _refresh_in_flight: bool = false
var _auth_config_path: String = DEFAULT_AUTH_CONFIG_PATH
func _ready() -> void:
_load_cached_session()
func _exit_tree() -> void:
for request in _active_requests:
if is_instance_valid(request):
request.cancel_request()
request.queue_free()
_active_requests.clear()
func is_authenticated() -> bool:
return not _access_token.strip_edges().is_empty()
func get_access_token() -> String:
return _access_token
func get_refresh_token() -> String:
return _refresh_token
func get_current_user() -> Dictionary:
return _current_user.duplicate(true)
func get_current_profile() -> Dictionary:
return _current_profile.duplicate(true)
func get_current_username() -> String:
return str(_current_user.get("username", ""))
func get_session_generation() -> int:
return _session_generation
func get_account_generation() -> int:
return _account_generation
func get_auth_config_path() -> String:
return _auth_config_path
func login(identifier: String, password: String) -> void:
var normalized_identifier := identifier.strip_edges()
if normalized_identifier.is_empty():
login_failed.emit("请输入用户名、邮箱或手机号")
return
if password.is_empty():
login_failed.emit("请输入密码")
return
_advance_account_generation()
_request_json("/auth/login", {
"identifier": normalized_identifier,
"password": password
}, _on_login_response, HTTPClient.METHOD_POST, false, true)
func send_email_verification(email: String) -> void:
var normalized_email := email.strip_edges()
if normalized_email.is_empty():
email_verification_failed.emit("请输入邮箱")
return
if not _is_valid_email(normalized_email):
email_verification_failed.emit("邮箱格式不正确")
return
_request_json("/auth/send-email-verification", {
"email": normalized_email
}, func(success: bool, _data: Dictionary, error_info: Dictionary) -> void:
if success:
email_verification_sent.emit(normalized_email)
else:
email_verification_failed.emit(str(error_info.get("message", "验证码发送失败")))
)
func register(username: String, password: String, nickname: String = "", email: String = "", email_verification_code: String = "", skin_id: String = "") -> void:
var normalized_username := username.strip_edges()
var normalized_nickname := nickname.strip_edges()
var normalized_email := email.strip_edges()
var normalized_code := email_verification_code.strip_edges()
if normalized_username.is_empty():
register_failed.emit("请输入用户名")
return
if not _is_valid_username(normalized_username):
register_failed.emit("用户名只能包含字母、数字和下划线,长度 1-50")
return
if password.length() < 8:
register_failed.emit("密码至少 8 位,并需要包含字母和数字")
return
if not _password_has_letter_and_number(password):
register_failed.emit("密码需要同时包含字母和数字")
return
if normalized_nickname.is_empty():
normalized_nickname = normalized_username
if normalized_email.is_empty():
register_failed.emit("请输入邮箱并先获取验证码")
return
if not _is_valid_email(normalized_email):
register_failed.emit("邮箱格式不正确")
return
if not _is_valid_email_code(normalized_code):
register_failed.emit("请输入邮件中的 6 位验证码")
return
var payload := {
"username": normalized_username,
"password": password,
"nickname": normalized_nickname,
"email": normalized_email,
"email_verification_code": normalized_code
}
var normalized_skin_id := skin_id.strip_edges()
if not normalized_skin_id.is_empty():
payload["skin_id"] = normalized_skin_id
_advance_account_generation()
_request_json("/auth/register", payload, _on_register_response, HTTPClient.METHOD_POST, false, true)
func fetch_profile() -> void:
if not is_authenticated():
return
var playerStateManager := get_node_or_null("/root/PlayerStateManager")
if playerStateManager != null and playerStateManager.has_method("refresh_snapshot"):
playerStateManager.call("refresh_snapshot")
func update_profile(profile_data: Dictionary) -> void:
if not is_authenticated():
profile_update_failed.emit("请先登录")
return
var playerStateManager := get_node_or_null("/root/PlayerStateManager")
if playerStateManager == null:
profile_update_failed.emit("玩家状态管理器未加载")
return
var assetPayload := profile_data.duplicate(true)
assetPayload.erase("skin_id")
assetPayload.erase("settings")
if profile_data.has("skin_id") and playerStateManager.has_method("update_appearance"):
playerStateManager.call(
"update_appearance",
str(profile_data.get("skin_id", "")),
Callable(self, "_on_profile_response")
)
if profile_data.has("settings") and playerStateManager.has_method("update_settings"):
var settingsVariant: Variant = profile_data.get("settings", {})
if settingsVariant is Dictionary:
playerStateManager.call(
"update_settings",
settingsVariant as Dictionary,
Callable(self, "_on_profile_response")
)
if not assetPayload.is_empty():
var api := get_node_or_null("/root/ApiClient")
if api != null and api.has_method("patch_json"):
var requestGeneration := _account_generation
api.call(
"patch_json",
"/player/profile-assets",
assetPayload,
Callable(self, "_on_profile_assets_response").bind(requestGeneration),
true
)
else:
profile_update_failed.emit("ApiClient 不可用")
return
if not profile_data.has("skin_id") and not profile_data.has("settings"):
profile_update_failed.emit("没有可保存的玩家资料")
func refresh_session() -> void:
if _refresh_token.strip_edges().is_empty() or _refresh_in_flight:
return
_refresh_in_flight = true
_request_json("/auth/refresh-token", {
"refresh_token": _refresh_token
}, _on_refresh_response, HTTPClient.METHOD_POST, false, true)
func logout() -> void:
_clear_session_memory()
if FileAccess.file_exists(_auth_config_path):
DirAccess.remove_absolute(_auth_config_path)
auth_state_changed.emit(false, {})
_emit_event(EventNames.AUTH_LOGOUT, {})
logout_completed.emit()
call_deferred("_return_to_auth_scene")
func _request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_POST, authenticated: bool = false, accountBound: bool = false) -> void:
var request := HTTPRequest.new()
request.timeout = REQUEST_TIMEOUT
request.set_meta("account_bound", accountBound)
add_child(request)
_active_requests.append(request)
var requestGeneration := _account_generation
request.request_completed.connect(func(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
_active_requests.erase(request)
request.queue_free()
if accountBound and requestGeneration != _account_generation:
return
_handle_json_response(result, response_code, body, callback)
)
var url := "%s%s" % [NetworkConfig.get_api_base_url(), endpoint]
var headers := PackedStringArray(["Content-Type: application/json"])
if authenticated and not _access_token.strip_edges().is_empty():
headers.append("Authorization: Bearer %s" % _access_token)
var body := "" if method == HTTPClient.METHOD_GET else JSON.stringify(payload)
var err := request.request(url, headers, method, body)
if err != OK:
_active_requests.erase(request)
request.queue_free()
if not accountBound or requestGeneration == _account_generation:
callback.call(false, {}, {"message": "网络请求发送失败: %s" % error_string(err)})
func _handle_json_response(result: int, response_code: int, body: PackedByteArray, callback: Callable) -> void:
var body_text := body.get_string_from_utf8()
if result != HTTPRequest.RESULT_SUCCESS:
callback.call(false, {}, {"message": "网络请求失败: %s" % _http_result_to_string(result)})
return
var json := JSON.new()
if json.parse(body_text) != OK:
callback.call(false, {}, {"message": "服务器响应解析失败"})
return
var payload_variant: Variant = json.data
if not (payload_variant is Dictionary):
callback.call(false, {}, {"message": "服务器响应格式错误"})
return
var response: Dictionary = payload_variant
var success := response_code >= 200 and response_code < 300 and bool(response.get("success", true))
if success:
callback.call(true, response, {})
return
callback.call(false, response, {
"message": str(response.get("message", "请求失败")),
"response_code": response_code,
"error_code": str(response.get("error_code", ""))
})
func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary) -> void:
if not success:
login_failed.emit(str(error_info.get("message", "登录失败")))
return
_apply_auth_payload(data)
if not is_authenticated():
login_failed.emit("登录响应缺少 access_token")
return
_emit_event(EventNames.AUTH_LOGIN_SUCCESS, {
"user": get_current_user()
})
login_succeeded.emit(get_current_user())
auth_state_changed.emit(true, get_current_user())
_refresh_player_snapshot()
func _on_register_response(success: bool, data: Dictionary, error_info: Dictionary) -> void:
if not success:
register_failed.emit(str(error_info.get("message", "注册失败")))
return
_apply_auth_payload(data)
if not is_authenticated():
register_failed.emit("注册响应缺少 access_token")
return
_emit_event(EventNames.AUTH_REGISTER_SUCCESS, {
"user": get_current_user()
})
register_succeeded.emit(get_current_user())
auth_state_changed.emit(true, get_current_user())
_refresh_player_snapshot()
func _on_refresh_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void:
_refresh_in_flight = false
if not success:
logout()
return
_apply_auth_payload(data)
auth_state_changed.emit(true, get_current_user())
_refresh_player_snapshot()
func _apply_auth_payload(payload: Dictionary) -> void:
var data_variant: Variant = payload.get("data", {})
if not (data_variant is Dictionary):
return
var auth_data: Dictionary = data_variant
_access_token = str(auth_data.get("access_token", "")).strip_edges()
_refresh_token = str(auth_data.get("refresh_token", _refresh_token)).strip_edges()
var user_variant: Variant = auth_data.get("user", {})
if user_variant is Dictionary:
_current_user = user_variant
var profile_variant: Variant = auth_data.get("profile", {})
if profile_variant is Dictionary:
_current_profile = profile_variant
_apply_profile_to_appearance_manager()
_session_generation += 1
_save_cached_session()
func _on_profile_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void:
if not success:
profile_update_failed.emit(str(_error_info.get("message", "玩家资料保存失败")))
auth_state_changed.emit(is_authenticated(), get_current_user())
return
var data_variant: Variant = data.get("data", {})
if not (data_variant is Dictionary):
profile_update_failed.emit("玩家状态响应格式错误")
auth_state_changed.emit(is_authenticated(), get_current_user())
return
var snapshot := data_variant as Dictionary
var playerStateManager := get_node_or_null("/root/PlayerStateManager")
if playerStateManager != null and playerStateManager.has_method("apply_snapshot"):
playerStateManager.call("apply_snapshot", snapshot)
_apply_snapshot_profile_payload(snapshot)
profile_update_succeeded.emit(get_current_profile())
auth_state_changed.emit(is_authenticated(), get_current_user())
func _on_profile_assets_response(success: bool, data: Dictionary, _error_info: Dictionary, requestGeneration: int) -> void:
if requestGeneration != _account_generation:
return
if not success:
profile_update_failed.emit(str(_error_info.get("message", "玩家资源更新失败")))
auth_state_changed.emit(is_authenticated(), get_current_user())
return
var dataVariant: Variant = data.get("data", {})
var playerStateManager := get_node_or_null("/root/PlayerStateManager")
if dataVariant is Dictionary and playerStateManager != null and playerStateManager.has_method("apply_snapshot"):
var snapshot := dataVariant as Dictionary
playerStateManager.call("apply_snapshot", snapshot)
_apply_snapshot_profile_payload(snapshot)
profile_update_succeeded.emit(get_current_profile())
auth_state_changed.emit(is_authenticated(), get_current_user())
func _apply_profile_to_appearance_manager() -> void:
if _current_profile.is_empty():
return
var appearanceManager := get_node_or_null("/root/AppearanceManager")
if appearanceManager != null and appearanceManager.has_method("apply_account_profile"):
appearanceManager.call("apply_account_profile", _current_profile)
var settingsVariant: Variant = _current_profile.get("settings", {})
var settingsManager := get_node_or_null("/root/SettingsManager")
if settingsVariant is Dictionary and settingsManager != null and settingsManager.has_method("apply_account_settings"):
settingsManager.call("apply_account_settings", settingsVariant as Dictionary)
func _apply_snapshot_profile_payload(snapshot: Dictionary) -> void:
var userVariant: Variant = snapshot.get("user", {})
if userVariant is Dictionary:
_current_user = (userVariant as Dictionary).duplicate(true)
var profileVariant: Variant = snapshot.get("profile", {})
if not (profileVariant is Dictionary):
return
var profile := (profileVariant as Dictionary).duplicate(true)
var appearance := {}
var appearanceVariant: Variant = snapshot.get("appearance", {})
if appearanceVariant is Dictionary:
appearance = (appearanceVariant as Dictionary).duplicate(true)
var selectedSkinId := str(appearance.get("selected_skin_id", profile.get("selected_skin_id", profile.get("skin_id", "")))).strip_edges()
profile["skin_id"] = selectedSkinId
profile["owned_skin_ids"] = appearance.get("owned_skin_ids", [])
profile["owned_skins"] = appearance.get("owned_skins", [])
_current_profile = profile
_apply_profile_to_appearance_manager()
_save_cached_session()
func _refresh_player_snapshot() -> void:
var playerStateManager := get_node_or_null("/root/PlayerStateManager")
if playerStateManager != null and playerStateManager.has_method("refresh_snapshot"):
playerStateManager.call_deferred("refresh_snapshot")
func _save_cached_session() -> void:
var config := ConfigFile.new()
config.set_value("auth", "refresh_token", _refresh_token)
config.set_value("auth", "access_token", _access_token)
config.set_value("auth", "saved_at", Time.get_unix_time_from_system())
var err := config.save(_auth_config_path)
if err != OK:
push_warning("AuthManager: 保存本地登录状态失败: %s" % error_string(err))
func _load_cached_session(emit_cached_state: bool = true) -> void:
if not FileAccess.file_exists(_auth_config_path):
return
var config := ConfigFile.new()
if config.load(_auth_config_path) != OK:
return
_refresh_token = str(config.get_value("auth", "refresh_token", "")).strip_edges()
_access_token = str(config.get_value("auth", "access_token", "")).strip_edges()
_current_user.clear()
_current_profile.clear()
if is_authenticated():
_session_generation += 1
_account_generation += 1
if emit_cached_state and is_authenticated():
call_deferred("_emit_cached_auth_state")
func _emit_cached_auth_state() -> void:
auth_state_changed.emit(true, get_current_user())
func _clear_session_memory() -> void:
_access_token = ""
_refresh_token = ""
_current_user.clear()
_current_profile.clear()
_advance_account_generation()
func _advance_account_generation() -> void:
_session_generation += 1
_account_generation += 1
_refresh_in_flight = false
for request in _active_requests.duplicate():
if is_instance_valid(request) and bool(request.get_meta("account_bound", false)):
request.cancel_request()
request.queue_free()
_active_requests.erase(request)
func _return_to_auth_scene() -> void:
var tree := get_tree()
if tree == null or tree.current_scene == null:
return
if tree.current_scene.scene_file_path == "res://scenes/ui/AuthScene.tscn":
return
var sceneManager := get_node_or_null("/root/SceneManager")
if sceneManager != null and sceneManager.has_method("change_scene"):
sceneManager.call("change_scene", "auth", false)
func _emit_event(event_name: String, data: Variant = null) -> void:
var eventSystem := get_node_or_null("/root/EventSystem")
if eventSystem == null:
return
eventSystem.call("emit_event", event_name, data)
func _is_valid_username(username: String) -> bool:
var regex := RegEx.new()
regex.compile("^[A-Za-z0-9_]{1,50}$")
return regex.search(username) != null
func _is_valid_email(email: String) -> bool:
var regex := RegEx.new()
regex.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")
return regex.search(email) != null
func _is_valid_email_code(code: String) -> bool:
var regex := RegEx.new()
regex.compile("^\\d{6}$")
return regex.search(code) != null
func _password_has_letter_and_number(password: String) -> bool:
var has_letter := false
var has_number := false
for i in range(password.length()):
var code := password.unicode_at(i)
if (code >= 65 and code <= 90) or (code >= 97 and code <= 122):
has_letter = true
if code >= 48 and code <= 57:
has_number = true
return has_letter and has_number
func _http_result_to_string(result: int) -> String:
match result:
HTTPRequest.RESULT_SUCCESS:
return "SUCCESS"
HTTPRequest.RESULT_TIMEOUT:
return "TIMEOUT"
HTTPRequest.RESULT_CANT_CONNECT:
return "CANT_CONNECT"
HTTPRequest.RESULT_CANT_RESOLVE:
return "CANT_RESOLVE"
HTTPRequest.RESULT_CONNECTION_ERROR:
return "CONNECTION_ERROR"
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
return "TLS_HANDSHAKE_ERROR"
_:
return "UNKNOWN_%d" % result

View File

@@ -0,0 +1 @@
uid://bcnqr6uw425hj

View File

@@ -0,0 +1,286 @@
extends Node
# ============================================================================
# CafeCompanionManager.gd - 咖啡店陪伴机器人网络管理器
# ============================================================================
# 负责咖啡店陪伴机器人服务点、付费聊天时长和 Agent
# 聊天代理的前端协议。客户端不保存模型 URL/token。
# ============================================================================
signal cafe_companion_products_ready(data: Dictionary)
signal cafe_companion_service_points_ready(data: Dictionary)
signal cafe_companion_chat_time_purchased(data: Dictionary)
signal cafe_companion_message_received(message: Dictionary)
signal cafe_companion_error_occurred(message: String)
signal cafe_companion_agent_registered(data: Dictionary)
signal cafe_companion_employment_resigned(data: Dictionary)
signal cafe_companion_models_ready(data: Dictionary)
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const REQUEST_TIMEOUT: float = 24.0
var _activeRequests: Array[HTTPRequest] = []
var _cachedProducts: Array = []
var _cachedServicePoints: Array = []
var _accountGeneration: int = -1
func _ready() -> void:
_accountGeneration = _current_account_generation()
var authManager := get_node_or_null("/root/AuthManager")
if authManager != null and authManager.has_signal("auth_state_changed"):
authManager.auth_state_changed.connect(_on_auth_state_changed)
func _exit_tree() -> void:
_cancel_active_requests()
func get_chat_time_products(forceRefresh: bool = false) -> void:
if not forceRefresh and not _cachedProducts.is_empty():
var cachedData := {"products": _cachedProducts}
cafe_companion_products_ready.emit(cachedData)
_emit_event(EventNames.CAFE_COMPANION_PRODUCTS_READY, cachedData)
return
if not _has_auth_token():
_emit_chat_error("请先登录后再查看咖啡店陪聊时长")
return
_request_json("/cafe-companion/chat-time-products", {}, _on_chat_time_products_response, HTTPClient.METHOD_GET)
func get_service_points(forceRefresh: bool = false) -> void:
if not forceRefresh and not _cachedServicePoints.is_empty():
var cachedData := {"service_points": _cachedServicePoints}
cafe_companion_service_points_ready.emit(cachedData)
_emit_event(EventNames.CAFE_COMPANION_SERVICE_POINTS_READY, cachedData)
return
if not _has_auth_token():
_emit_chat_error("请先登录后再查看咖啡店陪伴位")
return
_request_json("/cafe-companion/service-points", {}, _on_service_points_response, HTTPClient.METHOD_GET)
func purchase_chat_time(targetData: Dictionary, minutes: int) -> void:
if not _has_auth_token():
_emit_chat_error("请先登录后再购买咖啡店陪聊时长")
return
var payload := {
"service_point_id": str(targetData.get("service_point_id", "")),
"minutes": minutes,
}
var companionId := str(targetData.get("companion_id", "")).strip_edges()
if not companionId.is_empty():
payload["companion_id"] = companionId
_request_json("/cafe-companion/chat-time/purchase", payload, _on_purchase_chat_time_response)
func send_chat_message(sessionId: String, content: String) -> void:
var normalizedContent := content.strip_edges()
if sessionId.strip_edges().is_empty():
_emit_chat_error("咖啡店陪聊会话还没有准备好")
return
if normalizedContent.is_empty():
return
if not _has_auth_token():
_emit_chat_error("请先登录后再发送咖啡店陪聊消息")
return
_request_json("/cafe-companion/chat/messages", {
"session_id": sessionId,
"content": normalizedContent,
}, _on_send_chat_message_response)
func register_employment_agent(payload: Dictionary) -> void:
if not _has_auth_token():
_emit_chat_error("请先登录后再登记咖啡店陪伴机器人")
return
_request_json("/cafe-companion/employment/agents", payload, _on_register_employment_agent_response)
func resign_employment(servicePointId: String) -> void:
if not _has_auth_token():
_emit_chat_error("请先登录后再结束咖啡店雇佣")
return
_request_json("/cafe-companion/employment/resign", {
"service_point_id": servicePointId,
}, _on_resign_employment_response)
func get_employment_models(protocol: String, baseUrl: String, token: String) -> void:
if not _has_auth_token():
_emit_chat_error("请先登录后再获取咖啡店雇佣模型")
return
_request_json("/cafe-companion/employment/models", {
"protocol": protocol,
"base_url": baseUrl,
"token": token,
}, _on_employment_models_response)
func _request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_POST) -> void:
var request := HTTPRequest.new()
request.timeout = REQUEST_TIMEOUT
add_child(request)
_activeRequests.append(request)
var requestGeneration := _current_account_generation()
request.request_completed.connect(func(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
_activeRequests.erase(request)
request.queue_free()
if requestGeneration != _current_account_generation():
return
_handle_json_response(result, responseCode, body, callback)
)
var headers := PackedStringArray(["Content-Type: application/json"])
var token := _get_auth_token()
if not token.is_empty():
headers.append("Authorization: Bearer %s" % token)
var body := "" if method == HTTPClient.METHOD_GET else JSON.stringify(payload)
var err := request.request("%s%s" % [NetworkConfig.get_api_base_url(), endpoint], headers, method, body)
if err != OK:
_activeRequests.erase(request)
request.queue_free()
if requestGeneration == _current_account_generation():
_emit_chat_error("咖啡店陪聊请求发送失败: %s" % error_string(err))
func _handle_json_response(result: int, responseCode: int, body: PackedByteArray, callback: Callable) -> void:
if result != HTTPRequest.RESULT_SUCCESS:
_emit_chat_error("咖啡店陪聊网络请求失败: %s" % _http_result_to_string(result))
return
var bodyText := body.get_string_from_utf8()
var json := JSON.new()
if json.parse(bodyText) != OK:
_emit_chat_error("咖啡店陪聊响应解析失败")
return
var payloadVariant: Variant = json.data
if not (payloadVariant is Dictionary):
_emit_chat_error("咖啡店陪聊响应格式错误")
return
var response: Dictionary = payloadVariant
var success := responseCode >= 200 and responseCode < 300 and bool(response.get("success", true))
if not success:
_emit_chat_error(str(response.get("message", "咖啡店陪聊请求失败")))
return
callback.call(response)
func _on_chat_time_products_response(response: Dictionary) -> void:
var data := _response_data(response)
var productsVariant: Variant = data.get("products", [])
_cachedProducts = productsVariant if productsVariant is Array else []
cafe_companion_products_ready.emit(data)
_emit_event(EventNames.CAFE_COMPANION_PRODUCTS_READY, data)
func _on_service_points_response(response: Dictionary) -> void:
var data := _response_data(response)
var servicePointsVariant: Variant = data.get("service_points", [])
_cachedServicePoints = servicePointsVariant if servicePointsVariant is Array else []
cafe_companion_service_points_ready.emit(data)
_emit_event(EventNames.CAFE_COMPANION_SERVICE_POINTS_READY, data)
func _on_purchase_chat_time_response(response: Dictionary) -> void:
var data := _response_data(response)
_apply_wallet_from_payload(data)
cafe_companion_chat_time_purchased.emit(data)
_emit_event(EventNames.CAFE_COMPANION_CHAT_TIME_PURCHASED, data)
func _on_send_chat_message_response(response: Dictionary) -> void:
var data := _response_data(response)
cafe_companion_message_received.emit(data)
_emit_event(EventNames.CAFE_COMPANION_MESSAGE_RECEIVED, data)
func _on_register_employment_agent_response(response: Dictionary) -> void:
var data := _response_data(response)
cafe_companion_agent_registered.emit(data)
_cachedServicePoints.clear()
_emit_event(EventNames.CAFE_COMPANION_AGENT_REGISTERED, data)
func _on_resign_employment_response(response: Dictionary) -> void:
var data := _response_data(response)
var penaltyVariant: Variant = data.get("penalty", {})
if penaltyVariant is Dictionary:
_apply_wallet_from_payload(penaltyVariant as Dictionary)
cafe_companion_employment_resigned.emit(data)
_cachedServicePoints.clear()
_emit_event(EventNames.CAFE_COMPANION_EMPLOYMENT_RESIGNED, data)
func _on_employment_models_response(response: Dictionary) -> void:
var data := _response_data(response)
cafe_companion_models_ready.emit(data)
_emit_event(EventNames.CAFE_COMPANION_MODELS_READY, data)
func _response_data(response: Dictionary) -> Dictionary:
var dataVariant: Variant = response.get("data", {})
if dataVariant is Dictionary:
return dataVariant
return {}
func _apply_wallet_from_payload(data: Dictionary) -> void:
if not data.has("balance"):
return
var playerStateManager := get_node_or_null("/root/PlayerStateManager")
if playerStateManager != null and playerStateManager.has_method("apply_wallet"):
playerStateManager.call("apply_wallet", {
"balance": int(data.get("balance", 0)),
"currency": str(data.get("currency", "whale_coin")),
})
func _emit_chat_error(message: String) -> void:
cafe_companion_error_occurred.emit(message)
_emit_event(EventNames.CAFE_COMPANION_ERROR_OCCURRED, {
"message": message
})
func _emit_event(eventName: String, data: Dictionary) -> void:
var eventSystem := get_node_or_null("/root/EventSystem")
if eventSystem != null:
eventSystem.call("emit_event", eventName, data)
func _has_auth_token() -> bool:
return not _get_auth_token().is_empty()
func _get_auth_token() -> String:
var authManager := get_node_or_null("/root/AuthManager")
if authManager == null or not authManager.has_method("get_access_token"):
return ""
return str(authManager.call("get_access_token")).strip_edges()
func _current_account_generation() -> int:
var authManager := get_node_or_null("/root/AuthManager")
if authManager == null or not authManager.has_method("get_account_generation"):
return -1
return int(authManager.call("get_account_generation"))
func _on_auth_state_changed(_isAuthenticated: bool, _user: Dictionary) -> void:
var currentGeneration := _current_account_generation()
if currentGeneration == _accountGeneration:
return
_accountGeneration = currentGeneration
_cancel_active_requests()
_cachedProducts.clear()
_cachedServicePoints.clear()
func _cancel_active_requests() -> void:
for request in _activeRequests.duplicate():
if is_instance_valid(request):
request.cancel_request()
request.queue_free()
_activeRequests.clear()
func _http_result_to_string(result: int) -> String:
match result:
HTTPRequest.RESULT_SUCCESS:
return "SUCCESS"
HTTPRequest.RESULT_TIMEOUT:
return "TIMEOUT"
HTTPRequest.RESULT_CANT_CONNECT:
return "CANT_CONNECT"
HTTPRequest.RESULT_CANT_RESOLVE:
return "CANT_RESOLVE"
HTTPRequest.RESULT_CONNECTION_ERROR:
return "CONNECTION_ERROR"
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
return "TLS_HANDSHAKE_ERROR"
_:
return "HTTP_RESULT_%d" % result

View File

@@ -0,0 +1 @@
uid://nsdw0bspjy35

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
uid://biet5tng84app

View File

@@ -0,0 +1,150 @@
extends Node
# ============================================================================
# NotificationSoundManager.gd - 提醒音效管理器
# ============================================================================
# 监听聊天和好友事件,播放项目内正式 UI 提醒音效资源。
# ============================================================================
const MIN_PLAY_INTERVAL_SEC: float = 0.18
const WORLD_NOTIFICATION_PATH: String = "res://assets/audio/ui/notify_world.wav"
const PRIVATE_NOTIFICATION_PATH: String = "res://assets/audio/ui/notify_private.wav"
const FRIEND_NOTIFICATION_PATH: String = "res://assets/audio/ui/notify_friend.wav"
var _player: AudioStreamPlayer
var _worldStream: AudioStreamWAV
var _privateStream: AudioStreamWAV
var _friendStream: AudioStreamWAV
var _lastPlayedAt: float = 0.0
var _seenFriendRequestKeys: Dictionary = {}
func _ready() -> void:
_build_player()
_load_streams()
_subscribe_to_events()
func _exit_tree() -> void:
var eventSystem := _get_event_system()
if eventSystem != null:
eventSystem.call("disconnect_event", EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
eventSystem.call("disconnect_event", EventNames.CHAT_FRIENDS_UPDATED, _on_friends_updated, self)
eventSystem.call("disconnect_event", EventNames.CHAT_ERROR_OCCURRED, _on_chat_error, self)
func play_notification(kind: String = "world") -> void:
if _settings_bool("mute_ui_sfx", false):
return
var now := Time.get_unix_time_from_system()
if now - _lastPlayedAt < MIN_PLAY_INTERVAL_SEC:
return
_lastPlayedAt = now
if not is_instance_valid(_player):
return
var stream := _stream_for_kind(kind)
if stream == null:
return
_player.stream = stream
_player.play()
func should_play_message_notification(data: Dictionary) -> bool:
if bool(data.get("is_self", false)):
return false
var scope := str(data.get("scope", "")).strip_edges().to_lower()
var isPrivate := bool(data.get("is_private", false)) or scope == "private"
if isPrivate:
return _settings_bool("private_notifications", true)
return _settings_bool("world_notifications", true)
func get_notification_asset_path(kind: String = "world") -> String:
match kind:
"private":
return PRIVATE_NOTIFICATION_PATH
"friend":
return FRIEND_NOTIFICATION_PATH
_:
return WORLD_NOTIFICATION_PATH
func _build_player() -> void:
_player = AudioStreamPlayer.new()
_player.name = "NotificationAudioPlayer"
_player.bus = "SFX"
add_child(_player)
func _load_streams() -> void:
_worldStream = _load_wav_stream(WORLD_NOTIFICATION_PATH)
_privateStream = _load_wav_stream(PRIVATE_NOTIFICATION_PATH)
_friendStream = _load_wav_stream(FRIEND_NOTIFICATION_PATH)
func _load_wav_stream(path: String) -> AudioStreamWAV:
var stream := AudioStreamWAV.load_from_file(ProjectSettings.globalize_path(path))
if stream == null:
push_warning("NotificationSoundManager: 提醒音效加载失败: %s" % path)
return stream
func _stream_for_kind(kind: String) -> AudioStream:
match kind:
"private":
return _privateStream
"friend":
return _friendStream
_:
return _worldStream
func _subscribe_to_events() -> void:
var eventSystem := _get_event_system()
if eventSystem == null:
push_warning("NotificationSoundManager: EventSystem autoload is not available.")
return
eventSystem.call("connect_event", EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
eventSystem.call("connect_event", EventNames.CHAT_FRIENDS_UPDATED, _on_friends_updated, self)
eventSystem.call("connect_event", EventNames.CHAT_ERROR_OCCURRED, _on_chat_error, self)
func _on_chat_message_received(data: Dictionary) -> void:
if not should_play_message_notification(data):
return
var scope := str(data.get("scope", "")).strip_edges().to_lower()
var isPrivate := bool(data.get("is_private", false)) or scope == "private"
play_notification("private" if isPrivate else "world")
func _on_friends_updated(data: Dictionary) -> void:
if not _settings_bool("friend_request_notifications", true):
return
var requestsVariant: Variant = data.get("requests", [])
if not (requestsVariant is Array):
return
var shouldPlay := false
for requestVariant in requestsVariant:
if not (requestVariant is Dictionary):
continue
var key := _friend_request_key(requestVariant as Dictionary)
if key.is_empty() or _seenFriendRequestKeys.has(key):
continue
_seenFriendRequestKeys[key] = true
shouldPlay = true
if shouldPlay:
play_notification("friend")
func _on_chat_error(data: Dictionary) -> void:
var errorCode := str(data.get("error_code", ""))
if errorCode == "FRIEND_REQUEST_SENT" and _settings_bool("friend_request_notifications", true):
play_notification("friend")
func _friend_request_key(request: Dictionary) -> String:
var userId := str(request.get("user_id", request.get("userId", ""))).strip_edges()
if not userId.is_empty():
return userId
return str(request.get("username", "")).strip_edges()
func _settings_bool(key: String, defaultValue: bool) -> bool:
var settingsManager := get_node_or_null("/root/SettingsManager")
if settingsManager != null and settingsManager.has_method("get_bool"):
return bool(settingsManager.call("get_bool", key))
return defaultValue
func _get_event_system() -> Node:
return get_node_or_null("/root/EventSystem")

View File

@@ -0,0 +1 @@
uid://b8v2m3ivohfgw

View File

@@ -0,0 +1,227 @@
extends Node
# ============================================================================
# PlayerStateManager.gd - 当前玩家状态聚合管理器
# ============================================================================
# 缓存后端 /player/snapshot并向 UI 广播钱包、背包、外观和设置变化。
# ============================================================================
signal snapshot_changed(snapshot: Dictionary)
signal wallet_changed(wallet: Dictionary)
signal inventory_changed(inventory: Dictionary)
signal appearance_changed(appearance: Dictionary)
signal settings_changed(settings: Dictionary)
signal snapshot_failed(message: String)
var _snapshot: Dictionary = {}
var _wallet: Dictionary = {}
var _inventory: Dictionary = {}
var _appearance: Dictionary = {}
var _settings: Dictionary = {}
var _loadingSnapshot: bool = false
var _snapshotRequestGeneration: int = -1
func _ready() -> void:
var authManager := get_node_or_null("/root/AuthManager")
if authManager != null and authManager.has_signal("auth_state_changed"):
authManager.auth_state_changed.connect(_on_auth_state_changed)
func get_snapshot() -> Dictionary:
return _snapshot.duplicate(true)
func get_wallet() -> Dictionary:
return _wallet.duplicate(true)
func get_inventory() -> Dictionary:
return _inventory.duplicate(true)
func get_appearance() -> Dictionary:
return _appearance.duplicate(true)
func get_settings() -> Dictionary:
return _settings.duplicate(true)
func get_balance() -> int:
return int(_wallet.get("balance", 0))
func is_loaded() -> bool:
return not _snapshot.is_empty()
func refresh_snapshot() -> void:
if not _is_authenticated():
clear_state()
return
if _loadingSnapshot:
return
_loadingSnapshot = true
_snapshotRequestGeneration = _current_account_generation()
var api := _api_client()
if api == null:
_loadingSnapshot = false
_snapshotRequestGeneration = -1
snapshot_failed.emit("ApiClient 不可用")
return
api.call(
"get_json",
"/player/snapshot",
Callable(self, "_on_snapshot_response").bind(_snapshotRequestGeneration),
true
)
func apply_snapshot(snapshot: Dictionary) -> void:
_snapshot = snapshot.duplicate(true)
_wallet = _dictionary(snapshot.get("wallet", {}))
_inventory = _dictionary(snapshot.get("inventory", {}))
_appearance = _dictionary(snapshot.get("appearance", {}))
_settings = _dictionary(snapshot.get("settings", {}))
_apply_to_local_managers()
snapshot_changed.emit(get_snapshot())
wallet_changed.emit(get_wallet())
inventory_changed.emit(get_inventory())
appearance_changed.emit(get_appearance())
settings_changed.emit(get_settings())
func apply_wallet(wallet: Dictionary) -> void:
_wallet = wallet.duplicate(true)
if not _snapshot.is_empty():
_snapshot["wallet"] = _wallet.duplicate(true)
wallet_changed.emit(get_wallet())
func apply_inventory(inventory: Dictionary) -> void:
_inventory = inventory.duplicate(true)
if not _snapshot.is_empty():
_snapshot["inventory"] = _inventory.duplicate(true)
inventory_changed.emit(get_inventory())
_apply_inventory_to_appearance()
func apply_purchase_payload(payload: Dictionary) -> void:
var snapshotVariant: Variant = payload.get("snapshot", {})
if snapshotVariant is Dictionary:
apply_snapshot(snapshotVariant as Dictionary)
return
var walletVariant: Variant = payload.get("wallet", {})
if walletVariant is Dictionary:
apply_wallet(walletVariant as Dictionary)
var inventoryVariant: Variant = payload.get("inventory", {})
if inventoryVariant is Dictionary:
apply_inventory(inventoryVariant as Dictionary)
func update_appearance(skinId: String, completion: Callable = Callable()) -> void:
var api := _api_client()
if api == null:
var errorInfo := {"message": "ApiClient 不可用"}
snapshot_failed.emit(str(errorInfo.get("message")))
_call_update_completion(completion, false, {}, errorInfo)
return
var requestGeneration := _current_account_generation()
api.call(
"patch_json",
"/player/appearance",
{"skin_id": skinId},
Callable(self, "_on_update_response").bind(completion, requestGeneration),
true
)
func update_settings(settings: Dictionary, completion: Callable = Callable()) -> void:
var api := _api_client()
if api == null:
var errorInfo := {"message": "ApiClient 不可用"}
snapshot_failed.emit(str(errorInfo.get("message")))
_call_update_completion(completion, false, {}, errorInfo)
return
var requestGeneration := _current_account_generation()
api.call(
"patch_json",
"/player/settings",
{"settings": settings},
Callable(self, "_on_update_response").bind(completion, requestGeneration),
true
)
func clear_state() -> void:
_snapshot.clear()
_wallet.clear()
_inventory.clear()
_appearance.clear()
_settings.clear()
_loadingSnapshot = false
_snapshotRequestGeneration = -1
snapshot_changed.emit({})
wallet_changed.emit({})
inventory_changed.emit({})
appearance_changed.emit({})
settings_changed.emit({})
func _on_auth_state_changed(isAuthenticated: bool, _user: Dictionary) -> void:
if isAuthenticated:
if _snapshotRequestGeneration != _current_account_generation():
_loadingSnapshot = false
_snapshotRequestGeneration = -1
refresh_snapshot()
else:
clear_state()
func _on_snapshot_response(success: bool, response: Dictionary, errorInfo: Dictionary, requestGeneration: int) -> void:
if requestGeneration != _current_account_generation():
if requestGeneration == _snapshotRequestGeneration:
_loadingSnapshot = false
_snapshotRequestGeneration = -1
return
if requestGeneration == _snapshotRequestGeneration:
_loadingSnapshot = false
_snapshotRequestGeneration = -1
_apply_snapshot_response(success, response, errorInfo)
func _apply_snapshot_response(success: bool, response: Dictionary, errorInfo: Dictionary) -> void:
if not success:
snapshot_failed.emit(str(errorInfo.get("message", "玩家状态读取失败")))
return
var dataVariant: Variant = response.get("data", {})
if dataVariant is Dictionary:
apply_snapshot(dataVariant as Dictionary)
else:
snapshot_failed.emit("玩家状态格式错误")
func _on_update_response(success: bool, response: Dictionary, errorInfo: Dictionary, completion: Callable, requestGeneration: int) -> void:
if requestGeneration != _current_account_generation():
return
_apply_snapshot_response(success, response, errorInfo)
_call_update_completion(completion, success, response, errorInfo)
func _call_update_completion(completion: Callable, success: bool, response: Dictionary, errorInfo: Dictionary) -> void:
if completion.is_valid():
completion.call(success, response, errorInfo)
func _apply_to_local_managers() -> void:
_apply_inventory_to_appearance()
var appearanceManager := get_node_or_null("/root/AppearanceManager")
if appearanceManager != null and appearanceManager.has_method("apply_account_profile"):
var profile := _dictionary(_snapshot.get("profile", {}))
var appearance := _dictionary(_snapshot.get("appearance", {}))
profile["skin_id"] = str(appearance.get("selected_skin_id", profile.get("selected_skin_id", "")))
profile["owned_skin_ids"] = appearance.get("owned_skin_ids", [])
profile["owned_skins"] = appearance.get("owned_skins", [])
appearanceManager.call("apply_account_profile", profile)
var settingsManager := get_node_or_null("/root/SettingsManager")
if settingsManager != null and settingsManager.has_method("apply_account_settings"):
settingsManager.call("apply_account_settings", get_settings())
func _apply_inventory_to_appearance() -> void:
var skinIds: Variant = _inventory.get("skin_ids", [])
var appearanceManager := get_node_or_null("/root/AppearanceManager")
if skinIds is Array and appearanceManager != null and appearanceManager.has_method("set_owned_skin_ids"):
appearanceManager.call("set_owned_skin_ids", skinIds)
func _dictionary(value: Variant) -> Dictionary:
return (value as Dictionary).duplicate(true) if value is Dictionary else {}
func _api_client() -> Node:
return get_node_or_null("/root/ApiClient")
func _is_authenticated() -> bool:
var authManager := get_node_or_null("/root/AuthManager")
return bool(authManager.call("is_authenticated")) if authManager != null and authManager.has_method("is_authenticated") else false
func _current_account_generation() -> int:
var authManager := get_node_or_null("/root/AuthManager")
return int(authManager.call("get_account_generation")) if authManager != null and authManager.has_method("get_account_generation") else 0

View File

@@ -0,0 +1 @@
uid://cy1kwce6ngghv

View File

@@ -0,0 +1,183 @@
extends Node
signal decor_save_succeeded(item: Dictionary)
signal decor_save_failed(item: Dictionary, message: String)
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const REQUEST_TIMEOUT: float = 12.0
const MAX_SAVE_RETRIES: int = 3
const RETRY_BASE_DELAY: float = 0.75
var _request: HTTPRequest
var _retryTimer: Timer
var _queue: Array[Dictionary] = []
var _inFlight: Dictionary = {}
var _requestGeneration: int = -1
var _accountGeneration: int = -1
func _ready() -> void:
_request = HTTPRequest.new()
_request.name = "roomDecorSaveRequest"
_request.timeout = REQUEST_TIMEOUT
_request.request_completed.connect(_on_request_completed)
add_child(_request)
_retryTimer = Timer.new()
_retryTimer.name = "roomDecorSaveRetryTimer"
_retryTimer.one_shot = true
_retryTimer.timeout.connect(_process_next_save)
add_child(_retryTimer)
var authManager := get_node_or_null("/root/AuthManager")
if authManager != null and authManager.has_signal("auth_state_changed"):
_accountGeneration = _current_account_generation()
authManager.auth_state_changed.connect(_on_auth_state_changed)
func enqueue_save(item: Dictionary) -> bool:
if not _is_authenticated():
decor_save_failed.emit(item.duplicate(true), "请先登录后保存摆放")
return false
var decorId := str(item.get("decor_id", "")).strip_edges()
if decorId.is_empty():
decor_save_failed.emit(item.duplicate(true), "家具数据缺少 decor_id")
return false
# Only the newest queued placement for a decor matters.
for index in range(_queue.size() - 1, -1, -1):
var queuedItem: Dictionary = _queue[index].get("item", {})
if str(queuedItem.get("decor_id", "")) == decorId:
_queue.remove_at(index)
_queue.append({"item": item.duplicate(true), "retry_count": 0})
_process_next_save()
return true
func has_pending_saves() -> bool:
return not _inFlight.is_empty() or not _queue.is_empty() or (_retryTimer != null and not _retryTimer.is_stopped())
func _process_next_save() -> void:
if not _inFlight.is_empty() or _queue.is_empty() or not _is_authenticated():
return
if _retryTimer != null and not _retryTimer.is_stopped():
return
_inFlight = _queue.pop_front()
_requestGeneration = _current_account_generation()
var item: Dictionary = _inFlight.get("item", {})
var payload := {
"decor_id": str(item.get("decor_id", "")),
"placed": bool(item.get("placed", false)),
"position_x": float(item.get("position_x", 0.0)),
"position_y": float(item.get("position_y", 0.0)),
"scale": float(item.get("scale", item.get("default_scale", 1.0))),
"z_index": int(item.get("z_index", item.get("default_z_index", 0))),
}
var decorId := str(item.get("decor_id", "")).uri_encode()
var err := _request.request(
"%s/rooms/me/decor-placements/%s" % [NetworkConfig.get_api_base_url(), decorId],
_auth_headers(),
HTTPClient.METHOD_PUT,
JSON.stringify(payload)
)
if err != OK:
_retry_or_fail("家具位置保存请求发送失败")
func _on_request_completed(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
if _inFlight.is_empty():
return
if _requestGeneration != _current_account_generation():
_inFlight.clear()
_requestGeneration = -1
_process_next_save()
return
if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300:
_retry_or_fail(_read_error_message(body, "家具位置保存失败"))
return
var savedItem: Dictionary = (_inFlight.get("item", {}) as Dictionary).duplicate(true)
var json := JSON.new()
if json.parse(body.get_string_from_utf8()) == OK and json.data is Dictionary:
var response: Dictionary = json.data
if not bool(response.get("success", true)):
_retry_or_fail(str(response.get("message", "家具位置保存失败")))
return
var dataVariant: Variant = response.get("data", {})
if dataVariant is Dictionary:
savedItem = (dataVariant as Dictionary).duplicate(true)
_inFlight.clear()
_requestGeneration = -1
decor_save_succeeded.emit(savedItem)
_process_next_save()
func _retry_or_fail(message: String) -> void:
if _inFlight.is_empty():
return
var failedEntry := _inFlight.duplicate(true)
var failedItem: Dictionary = failedEntry.get("item", {})
var decorId := str(failedItem.get("decor_id", ""))
_inFlight.clear()
_requestGeneration = -1
if _queue_has_decor(decorId):
_process_next_save()
return
var retryCount := int(failedEntry.get("retry_count", 0)) + 1
if retryCount <= MAX_SAVE_RETRIES and _is_authenticated():
failedEntry["retry_count"] = retryCount
_queue.push_front(failedEntry)
_retryTimer.start(RETRY_BASE_DELAY * pow(2.0, retryCount - 1))
return
decor_save_failed.emit(failedItem.duplicate(true), message)
_process_next_save()
func _queue_has_decor(decorId: String) -> bool:
for entryVariant in _queue:
if entryVariant is Dictionary:
var item: Dictionary = (entryVariant as Dictionary).get("item", {})
if str(item.get("decor_id", "")) == decorId:
return true
return false
func _on_auth_state_changed(_isAuthenticated: bool, _user: Dictionary) -> void:
var currentGeneration := _current_account_generation()
if currentGeneration == _accountGeneration:
return
_accountGeneration = currentGeneration
if is_instance_valid(_request):
_request.cancel_request()
if is_instance_valid(_retryTimer):
_retryTimer.stop()
_queue.clear()
_inFlight.clear()
_requestGeneration = -1
func _auth_headers() -> PackedStringArray:
var authManager := get_node_or_null("/root/AuthManager")
var accessToken := str(authManager.call("get_access_token")).strip_edges() if authManager != null and authManager.has_method("get_access_token") else ""
return PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % accessToken,
])
func _is_authenticated() -> bool:
var authManager := get_node_or_null("/root/AuthManager")
return authManager != null and authManager.has_method("is_authenticated") and bool(authManager.call("is_authenticated"))
func _current_account_generation() -> int:
var authManager := get_node_or_null("/root/AuthManager")
return int(authManager.call("get_account_generation")) if authManager != null and authManager.has_method("get_account_generation") else -1
func _read_error_message(body: PackedByteArray, fallback: String) -> String:
var json := JSON.new()
if json.parse(body.get_string_from_utf8()) != OK or not (json.data is Dictionary):
return fallback
var response: Dictionary = json.data
var messageVariant: Variant = response.get("message", fallback)
if messageVariant is Array:
var parts: Array[String] = []
for part in messageVariant:
parts.append(str(part))
return "; ".join(parts) if not parts.is_empty() else fallback
var message := str(messageVariant).strip_edges()
return message if not message.is_empty() else fallback

View File

@@ -0,0 +1 @@
uid://cribngv3l36a

View File

@@ -0,0 +1,244 @@
extends Node
# ============================================================================
# SceneManager.gd - 场景管理器
# ============================================================================
# 全局单例管理器,负责场景切换和管理
#
# 核心职责:
# - 场景切换的统一接口
# - 场景路径映射管理
# - 场景切换过渡效果
# - 场景状态跟踪
#
# 使用方式:
# SceneManager.change_scene("main")
# SceneManager.register_scene("custom", "res://scenes/custom.tscn")
#
# 注意事项:
# - 作为自动加载单例,全局可访问
# - 场景切换是异步操作,支持过渡效果
# - 场景名称必须在 scene_paths 中注册
# ============================================================================
# ============ 信号定义 ============
# 场景切换完成信号
# 参数: scene_name - 切换到的场景名称
signal scene_changed(scene_name: String)
# 场景切换开始信号
# 参数: scene_name - 即将切换到的场景名称
signal scene_change_started(scene_name: String)
# ============ 成员变量 ============
# 场景状态
var current_scene_name: String = "" # 当前场景名称
var is_changing_scene: bool = false # 是否正在切换场景
var _next_scene_position: Variant = null # 下一个场景的初始位置 (Vector2 or null)
var _next_spawn_name: String = "" # 下一个场景的出生点名称 (String)
# 场景路径映射表
# 将场景名称映射到实际的文件路径
# 便于统一管理和修改场景路径
var scene_paths: Dictionary = {
"main": "res://scenes/ui/AuthScene.tscn", # 认证入口
"auth": "res://scenes/ui/AuthScene.tscn", # 登录/注册
"square": "res://scenes/Maps/square.tscn", # 广场地图
"work_zone": "res://scenes/Maps/work_zone.tscn", # 打工区地图
"cafe_interior": "res://scenes/Maps/cafe_interior.tscn", # 咖啡馆室内
"room": "res://scenes/Maps/personal_space.tscn", # 房间地图(兼容旧入口)
"personal_space": "res://scenes/Maps/personal_space.tscn", # 个人空间
"fountain": "res://scenes/Maps/fountain.tscn", # 喷泉地图
"datawhale_home": "res://scenes/Maps/datawhale_home.tscn", # 数据鲸鱼之家
"community": "res://scenes/Maps/community.tscn" # 社区地图
}
# ============ 场景切换方法 ============
# 切换到指定场景
#
# 参数:
# scene_name: String - 要切换到的场景名称必须在scene_paths中注册
# use_transition: bool - 是否使用过渡效果默认为true
#
# 返回值:
# bool - 切换是否成功
#
# 功能:
# - 检查场景切换状态和场景是否存在
# - 显示过渡效果(可选)
# - 执行场景切换
# - 更新当前场景状态
# - 发送相关信号
#
# 使用示例:
# var success = SceneManager.change_scene("main", true)
#
# 注意事项:
# - 场景切换是异步操作
# - 切换过程中会阻止新的切换请求
# - 场景名称必须预先注册
func change_scene(scene_name: String, use_transition: bool = true):
# 防止重复切换
if is_changing_scene:
push_warning("SceneManager: 场景切换中,忽略新的切换请求")
return false
# 检查场景是否存在
if not scene_paths.has(scene_name):
push_error("SceneManager: 未找到场景 %s" % scene_name)
return false
var scene_path = scene_paths[scene_name]
# 设置切换状态
is_changing_scene = true
scene_change_started.emit(scene_name)
# 显示过渡效果
if use_transition:
await show_transition()
# 执行场景切换
var error = get_tree().change_scene_to_file(scene_path)
if error != OK:
push_error("SceneManager: 场景切换失败 %s -> %s, 错误码: %d" % [current_scene_name, scene_name, error])
is_changing_scene = false
return false
# 更新状态
current_scene_name = scene_name
is_changing_scene = false
scene_changed.emit(scene_name)
# 隐藏过渡效果
if use_transition:
await hide_transition()
return true
# ============ 查询方法 ============
# 获取当前场景名称
#
# 返回值:
# String - 当前场景的名称
func get_current_scene_name() -> String:
return current_scene_name
# ============ 场景位置和出生点管理 ============
# 设置下一个场景的初始位置
#
# 参数:
# pos: Vector2 - 玩家在下一个场景的初始位置
#
# 功能:
# - 用于场景切换时传递玩家位置信息
# - 配合 DoorTeleport 等传送机制使用
#
# 使用示例:
# SceneManager.set_next_scene_position(Vector2(100, 200))
# SceneManager.change_scene("room")
func set_next_scene_position(pos: Vector2) -> void:
_next_scene_position = pos
# 获取并清除下一个场景的初始位置
#
# 返回值:
# Variant - Vector2 位置或 null如果未设置
#
# 功能:
# - 获取预设的场景初始位置
# - 获取后自动清除,避免影响后续场景切换
#
# 注意事项:
# - 此方法会清除存储的位置,只能获取一次
# - 如果未设置位置,返回 null
func get_next_scene_position() -> Variant:
var pos = _next_scene_position
_next_scene_position = null
return pos
# 设置下一个场景的出生点名称
#
# 参数:
# spawn_name: String - 出生点节点的名称
#
# 功能:
# - 指定玩家在下一个场景应该出现在哪个出生点
# - 配合场景中的 Marker2D 出生点使用
#
# 使用示例:
# SceneManager.set_next_spawn_name("DoorExit")
# SceneManager.change_scene("square")
func set_next_spawn_name(spawn_name: String) -> void:
_next_spawn_name = spawn_name
# 获取并清除下一个场景的出生点名称
#
# 返回值:
# String - 出生点名称(如果未设置则返回空字符串)
#
# 功能:
# - 获取预设的出生点名称
# - 获取后自动清除,避免影响后续场景切换
#
# 注意事项:
# - 此方法会清除存储的名称,只能获取一次
# - 如果未设置名称,返回空字符串
func get_next_spawn_name() -> String:
var spawn_name: String = _next_spawn_name
_next_spawn_name = ""
return spawn_name
# ============ 场景注册方法 ============
# 注册新场景
#
# 参数:
# scene_name: String - 场景名称(用于切换时引用)
# scene_path: String - 场景文件路径
#
# 功能:
# - 将场景名称和路径添加到映射表
# - 支持运行时动态注册场景
#
# 使用示例:
# SceneManager.register_scene("boss_battle", "res://scenes/boss/boss_battle.tscn")
func register_scene(scene_name: String, scene_path: String):
scene_paths[scene_name] = scene_path
# ============ 过渡效果方法 ============
# 显示场景切换过渡效果
#
# 功能:
# - 显示场景切换时的过渡动画
# - 为用户提供视觉反馈
#
# 注意事项:
# - 这是异步方法需要await等待完成
# - 当前实现为简单的延时,可扩展为复杂动画
#
# TODO: 实现淡入淡出、滑动等过渡效果
func show_transition():
# TODO: 实现场景切换过渡效果(当前仅占位延时)
await get_tree().create_timer(0.2).timeout
# 隐藏场景切换过渡效果
#
# 功能:
# - 隐藏场景切换完成后的过渡动画
# - 恢复正常的游戏显示
#
# 注意事项:
# - 这是异步方法需要await等待完成
# - 与show_transition()配对使用
#
# TODO: 实现与show_transition()对应的隐藏效果
func hide_transition():
# TODO: 隐藏场景切换过渡效果(当前仅占位延时)
await get_tree().create_timer(0.2).timeout

View File

@@ -0,0 +1 @@
uid://osl0yxxjjro2

View File

@@ -0,0 +1,136 @@
extends Node
# ============================================================================
# SettingsManager.gd - 游戏设置偏好管理器
# ============================================================================
# 统一应用账号级设置,并向运行中的 UI、聊天和角色系统广播变更。
# 设置持久化以后端账号 profile 为准,本管理器不再读写 user:// 配置文件。
# ============================================================================
const BASE_VIEWPORT_SIZE: Vector2i = Vector2i(1920, 1440)
const DEFAULT_SETTINGS: Dictionary = {
"master_volume": 0.80,
"music_volume": 0.60,
"effects_volume": 0.90,
"ui_scale": 1.00,
"fullscreen": false,
"show_interaction_hints": false,
"show_name_always": false,
"show_chat_bubbles": true,
"world_notifications": true,
"private_notifications": true,
"friend_request_notifications": true,
"allow_nearby_private": true,
"allow_nearby_friend_requests": true,
"mute_ui_sfx": false,
}
var _settings: Dictionary = DEFAULT_SETTINGS.duplicate(true)
func _ready() -> void:
_connect_auth_events()
reset_to_defaults(false)
apply_all_settings()
func load_settings() -> void:
_settings = DEFAULT_SETTINGS.duplicate(true)
apply_all_settings()
_emit_settings_changed()
func save_settings() -> bool:
_emit_settings_changed()
return true
func get_default_settings() -> Dictionary:
return DEFAULT_SETTINGS.duplicate(true)
func get_settings() -> Dictionary:
return _settings.duplicate(true)
func set_settings(settings: Dictionary, saveImmediately: bool = true) -> void:
for key in DEFAULT_SETTINGS.keys():
if settings.has(key):
_settings[key] = settings[key]
apply_all_settings()
if saveImmediately:
save_settings()
else:
_emit_settings_changed()
func apply_account_settings(settings: Dictionary) -> void:
set_settings(settings, false)
func reset_to_defaults(emitChanged: bool = true) -> void:
_settings = DEFAULT_SETTINGS.duplicate(true)
apply_all_settings()
if emitChanged:
_emit_settings_changed()
func set_setting(key: String, value: Variant, saveImmediately: bool = true) -> void:
if not DEFAULT_SETTINGS.has(key):
return
_settings[key] = value
apply_all_settings()
if saveImmediately:
save_settings()
else:
_emit_settings_changed()
func get_setting(key: String, fallback: Variant = null) -> Variant:
return _settings.get(key, fallback if fallback != null else DEFAULT_SETTINGS.get(key))
func get_bool(key: String) -> bool:
return bool(_settings.get(key, DEFAULT_SETTINGS.get(key, false)))
func get_float(key: String) -> float:
return float(_settings.get(key, DEFAULT_SETTINGS.get(key, 0.0)))
func apply_all_settings() -> void:
_apply_fullscreen_setting()
_apply_audio_settings()
_apply_ui_scale_setting()
func _apply_audio_settings() -> void:
_apply_bus_volume("Master", get_float("master_volume"))
_apply_bus_volume("Music", get_float("music_volume"))
_apply_bus_volume("SFX", get_float("effects_volume"))
func _apply_bus_volume(busName: String, value: float) -> void:
var busIndex := AudioServer.get_bus_index(busName)
if busIndex < 0:
return
var clamped := clampf(value, 0.0, 1.0)
AudioServer.set_bus_mute(busIndex, clamped <= 0.001)
if clamped > 0.001:
AudioServer.set_bus_volume_db(busIndex, linear_to_db(clamped))
func _apply_fullscreen_setting() -> void:
if DisplayServer.get_name() == "headless":
return
var fullscreen := get_bool("fullscreen")
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN if fullscreen else DisplayServer.WINDOW_MODE_WINDOWED)
func _apply_ui_scale_setting() -> void:
var viewport := get_tree().root
if viewport == null:
return
var scale := clampf(get_float("ui_scale"), 0.8, 1.2)
viewport.content_scale_size = Vector2i(
int(round(float(BASE_VIEWPORT_SIZE.x) / scale)),
int(round(float(BASE_VIEWPORT_SIZE.y) / scale))
)
func _emit_settings_changed() -> void:
var eventSystem := get_node_or_null("/root/EventSystem")
if eventSystem != null:
eventSystem.call("emit_event", EventNames.SETTINGS_CHANGED, get_settings())
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:
reset_to_defaults()

View File

@@ -0,0 +1 @@
uid://ti504x6wnfr

View File

@@ -0,0 +1,462 @@
extends Node
class_name ChatWebSocketManager
# ============================================================================
# WebSocketManager.gd - WebSocket 连接生命周期管理(原生 WebSocket 版本)
# ============================================================================
# 管理 WebSocket 连接状态、自动重连和错误恢复
#
# 核心职责:
# - 连接状态管理(断开、连接中、已连接、重连中)
# - 自动重连机制(指数退避)
# - 连接错误恢复
# - WebSocket 消息发送/接收
# ============================================================================
# 使用方式:
# WebSocketManager.connect_to_game_server()
# WebSocketManager.connection_state_changed.connect(_on_state_changed)
#
# 注意事项:
# - 作为自动加载单例,全局可访问
# - 自动处理连接断开和重连
# - 通过信号通知连接状态变化
# ============================================================================
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
# ============================================================================
# 信号定义
# ============================================================================
# 连接状态变化信号
# 参数:
# new_state: ConnectionState - 新的连接状态
signal connection_state_changed(new_state: ConnectionState)
# 连接丢失信号
signal connection_lost()
# 重连成功信号
signal reconnection_succeeded()
# 重连失败信号
# 参数:
# attempt: int - 当前重连尝试次数
# max_attempts: int - 最大重连次数
signal reconnection_failed(attempt: int, max_attempts: int)
# WebSocket 消息接收信号
# 参数:
# message: String - 接收到的消息内容JSON 字符串)
signal data_received(message: String)
# ============================================================================
# 枚举定义
# ============================================================================
# 连接状态枚举
enum ConnectionState {
DISCONNECTED, # 未连接
CONNECTING, # 连接中
CONNECTED, # 已连接
RECONNECTING, # 重连中
ERROR # 错误状态
}
# ============================================================================
# 常量定义
# ============================================================================
# WebSocket 服务器 URL原生 WebSocket
const WEBSOCKET_URL: String = "wss://whaletownend.xinghangee.icu/game"
# 默认最大重连次数
const DEFAULT_MAX_RECONNECT_ATTEMPTS: int = 5
# 默认重连基础延迟(秒)
const DEFAULT_RECONNECT_BASE_DELAY: float = 3.0
# 最大重连延迟(秒)
const MAX_RECONNECT_DELAY: float = 30.0
# ============================================================================
# 成员变量
# ============================================================================
# WebSocket peer
var _websocket_peer: WebSocketPeer = WebSocketPeer.new()
# 当前 WebSocket 地址
var _websocket_url: String = WEBSOCKET_URL
# 当前连接状态
var _connection_state: ConnectionState = ConnectionState.DISCONNECTED
# 自动重连启用标志
var _auto_reconnect_enabled: bool = true
# 最大重连次数
var _max_reconnect_attempts: int = DEFAULT_MAX_RECONNECT_ATTEMPTS
# 重连基础延迟
var _reconnect_base_delay: float = DEFAULT_RECONNECT_BASE_DELAY
# 当前重连尝试次数
var _reconnect_attempt: int = 0
# 重连定时器
var _reconnect_timer: Timer
# 只有客户端主动断开时才抑制自动重连。
var _manual_disconnect_requested: bool = false
# 当前 CLOSED 状态是否已经处理过(防止每帧重复处理 close 事件)
var _closed_state_handled: bool = false
# 心跳定时器
var _heartbeat_timer: Timer
# 心跳间隔(秒)
const HEARTBEAT_INTERVAL: float = 30.0
# ============================================================================
# 生命周期方法
# ============================================================================
# 初始化
func _ready() -> void:
_websocket_url = NetworkConfig.get_chat_ws_url()
# 设置重连定时器
_setup_reconnect_timer()
# 设置心跳定时器
_setup_heartbeat_timer()
# 启动处理循环
set_process(true)
# 处理每帧
func _process(_delta: float) -> void:
# 检查 WebSocket 状态变化
_check_websocket_state()
var state: WebSocketPeer.State = _websocket_peer.get_ready_state()
if state == WebSocketPeer.STATE_OPEN:
# 处理收到的数据
while _websocket_peer.get_available_packet_count() > 0:
var packet: PackedByteArray = _websocket_peer.get_packet()
var message: String = packet.get_string_from_utf8()
# 发射消息接收信号
data_received.emit(message)
# 清理
func _exit_tree() -> void:
_disconnect()
if is_instance_valid(_reconnect_timer):
_reconnect_timer.stop()
if is_instance_valid(_heartbeat_timer):
_heartbeat_timer.stop()
# ============================================================================
# 公共 API - 连接管理
# ============================================================================
# 连接到游戏服务器
func connect_to_game_server(is_reconnect_attempt: bool = false) -> void:
if _connection_state == ConnectionState.CONNECTED or _connection_state == ConnectionState.CONNECTING:
push_warning("已经在连接或已连接状态")
return
if is_reconnect_attempt:
_set_connection_state(ConnectionState.RECONNECTING)
else:
_set_connection_state(ConnectionState.CONNECTING)
_manual_disconnect_requested = false
_closed_state_handled = false
# 仅在首次/手动连接时重置重连计数,重连流程保持累计尝试次数
if not is_reconnect_attempt:
_reconnect_attempt = 0
_websocket_peer = WebSocketPeer.new()
var err: Error = _websocket_peer.connect_to_url(_websocket_url)
if err != OK:
push_error("WebSocketManager: 连接失败url=%s - %s" % [_websocket_url, error_string(err)])
_closed_state_handled = true
if _auto_reconnect_enabled:
_attempt_reconnect()
else:
_set_connection_state(ConnectionState.ERROR)
return
# 启动心跳
_start_heartbeat()
# 断开 WebSocket 连接
func disconnect_websocket() -> void:
_disconnect()
# 断开连接(内部方法)
func _disconnect() -> void:
_manual_disconnect_requested = true
# 停止重连定时器
if is_instance_valid(_reconnect_timer):
_reconnect_timer.stop()
# 停止心跳
if is_instance_valid(_heartbeat_timer):
_heartbeat_timer.stop()
# 关闭 WebSocket
if _websocket_peer.get_ready_state() == WebSocketPeer.STATE_OPEN:
_websocket_peer.close(1000, "Client disconnect")
# CONNECTING 状态无法可靠地原地取消;替换 peer 可隔离旧连接的后续状态。
_websocket_peer = WebSocketPeer.new()
_closed_state_handled = true
_set_connection_state(ConnectionState.DISCONNECTED)
# 检查 WebSocket 是否已连接
#
# 返回值:
# bool - WebSocket 是否已连接
func is_websocket_connected() -> bool:
return _connection_state == ConnectionState.CONNECTED
# 获取当前连接状态
#
# 返回值:
# ConnectionState - 当前连接状态
func get_connection_state() -> ConnectionState:
return _connection_state
# ============================================================================
# 公共 API - 消息发送
# ============================================================================
# 发送 WebSocket 消息
#
# 参数:
# message: String - 要发送的消息内容JSON 字符串)
#
# 返回值:
# Error - 错误码OK 表示成功
func send_message(message: String) -> Error:
if _websocket_peer.get_ready_state() != WebSocketPeer.STATE_OPEN:
push_warning("WebSocketManager: 未连接,无法发送消息")
return ERR_UNCONFIGURED
var err: Error = _websocket_peer.send_text(message)
if err != OK:
push_error("WebSocketManager: 发送消息失败 - %s" % error_string(err))
return err
return OK
# ============================================================================
# 公共 API - 自动重连
# ============================================================================
# 启用/禁用自动重连
#
# 参数:
# enabled: bool - 是否启用自动重连
# max_attempts: int - 最大重连次数(默认 5
# base_delay: float - 基础重连延迟,秒(默认 3.0
#
# 使用示例:
# WebSocketManager.enable_auto_reconnect(true, 5, 3.0)
func enable_auto_reconnect(enabled: bool, max_attempts: int = DEFAULT_MAX_RECONNECT_ATTEMPTS, base_delay: float = DEFAULT_RECONNECT_BASE_DELAY) -> void:
_auto_reconnect_enabled = enabled
_max_reconnect_attempts = max_attempts
_reconnect_base_delay = base_delay
# 获取重连信息
#
# 返回值:
# Dictionary - 重连信息 {enabled, attempt, max_attempts, delay}
func get_reconnect_info() -> Dictionary:
return {
"enabled": _auto_reconnect_enabled,
"attempt": _reconnect_attempt,
"max_attempts": _max_reconnect_attempts,
"next_delay": _calculate_reconnect_delay() if _connection_state == ConnectionState.RECONNECTING else 0.0
}
# ============================================================================
# 内部方法 - 连接状态管理
# ============================================================================
# 设置连接状态
func _set_connection_state(new_state: ConnectionState) -> void:
if _connection_state == new_state:
return
_connection_state = new_state
# 发射信号
connection_state_changed.emit(new_state)
# ============================================================================
# 内部方法 - WebSocket 状态监控
# ============================================================================
# 检查 WebSocket 状态变化
func _check_websocket_state() -> void:
# 必须先 poll 才能获取最新状态
_websocket_peer.poll()
var state: WebSocketPeer.State = _websocket_peer.get_ready_state()
match state:
WebSocketPeer.STATE_CONNECTING:
_closed_state_handled = false
# 正在连接
if _connection_state != ConnectionState.CONNECTING and _connection_state != ConnectionState.RECONNECTING:
_set_connection_state(ConnectionState.CONNECTING)
WebSocketPeer.STATE_OPEN:
_closed_state_handled = false
# 连接成功
if _connection_state != ConnectionState.CONNECTED:
_on_websocket_connected()
WebSocketPeer.STATE_CLOSING:
_closed_state_handled = false
# 正在关闭
pass
WebSocketPeer.STATE_CLOSED:
if _closed_state_handled:
return
_closed_state_handled = true
# 仅在连接生命周期中发生的关闭才触发关闭处理
var should_handle_close: bool = (
_connection_state == ConnectionState.CONNECTED
or _connection_state == ConnectionState.CONNECTING
or _connection_state == ConnectionState.RECONNECTING
)
if should_handle_close:
_on_websocket_closed()
# WebSocket 连接成功处理
func _on_websocket_connected() -> void:
# 如果是重连,发射重连成功信号
if _connection_state == ConnectionState.RECONNECTING:
_reconnect_attempt = 0
reconnection_succeeded.emit()
_set_connection_state(ConnectionState.CONNECTED)
# WebSocket 连接关闭处理
func _on_websocket_closed() -> void:
if not _manual_disconnect_requested and _auto_reconnect_enabled:
connection_lost.emit()
_attempt_reconnect()
else:
_set_connection_state(ConnectionState.DISCONNECTED)
# ============================================================================
# 内部方法 - 重连机制
# ============================================================================
# 设置重连定时器
func _setup_reconnect_timer() -> void:
_reconnect_timer = Timer.new()
_reconnect_timer.one_shot = true
_reconnect_timer.autostart = false
add_child(_reconnect_timer)
_reconnect_timer.timeout.connect(_on_reconnect_timeout)
# 尝试重连
func _attempt_reconnect() -> void:
# 检查是否超过最大重连次数
if _reconnect_attempt >= _max_reconnect_attempts:
push_error("WebSocketManager: 达到最大重连次数 (%d),停止重连" % _max_reconnect_attempts)
reconnection_failed.emit(_reconnect_attempt, _max_reconnect_attempts)
_set_connection_state(ConnectionState.ERROR)
return
_reconnect_attempt += 1
_set_connection_state(ConnectionState.RECONNECTING)
# 计算重连延迟(指数退避)
var delay: float = _calculate_reconnect_delay()
# 启动重连定时器
_reconnect_timer.start(delay)
# 计算重连延迟(指数退避)
func _calculate_reconnect_delay() -> float:
# 指数退避: base_delay * 2^(attempt-1)
var delay: float = _reconnect_base_delay * pow(2.0, _reconnect_attempt - 1)
# 限制最大延迟
return min(delay, MAX_RECONNECT_DELAY)
# 重连定时器超时处理
func _on_reconnect_timeout() -> void:
connect_to_game_server(true)
# ============================================================================
# 内部方法 - 心跳机制
# ============================================================================
# 设置心跳定时器
func _setup_heartbeat_timer() -> void:
_heartbeat_timer = Timer.new()
_heartbeat_timer.wait_time = HEARTBEAT_INTERVAL
_heartbeat_timer.one_shot = false
_heartbeat_timer.autostart = false
add_child(_heartbeat_timer)
_heartbeat_timer.timeout.connect(_on_heartbeat)
# 启动心跳
func _start_heartbeat() -> void:
_heartbeat_timer.start()
# 停止心跳
func _stop_heartbeat() -> void:
_heartbeat_timer.stop()
# 心跳超时处理
func _on_heartbeat() -> void:
# 不发送心跳,避免服务器返回 "消息格式错误"
# 如果需要心跳,服务器应该支持特定格式
pass
# ============================================================================
# 工具方法
# ============================================================================
# 获取连接状态描述
#
# 返回值:
# String - 连接状态描述
func get_state_description() -> String:
match _connection_state:
ConnectionState.DISCONNECTED:
return "未连接"
ConnectionState.CONNECTING:
return "连接中"
ConnectionState.CONNECTED:
return "已连接"
ConnectionState.RECONNECTING:
return "重连中 (%d/%d)" % [_reconnect_attempt, _max_reconnect_attempts]
ConnectionState.ERROR:
return "错误"
_:
return "未知状态"

View File

@@ -0,0 +1 @@
uid://brsajpuxddi12