forked from xiangwang25/whale-town-front-v2
feat: expand multiplayer, chat, and release support
- add network NPC synchronization and dialogue interactions - add world bulletin publishing and display - improve authentication, session refresh, and appearance sync - synchronize player direction and movement animations - improve input focus and progressive Web loading - add macOS/Windows builds and deployment configuration - include required fonts, shaders, and runtime assets
This commit is contained in:
@@ -19,6 +19,12 @@ const REMOTE_PLAYER_JOINED = "remote_player_joined"
|
||||
const REMOTE_PLAYER_LEFT = "remote_player_left"
|
||||
const REMOTE_PLAYER_POSITION_UPDATED = "remote_player_position_updated"
|
||||
const REMOTE_PLAYERS_SNAPSHOT_READY = "remote_players_snapshot_ready"
|
||||
const NPC_SNAPSHOT_READY = "npc_snapshot_ready"
|
||||
const NPC_ACTION_STARTED = "npc_action_started"
|
||||
const NPC_ACTION_COMPLETED = "npc_action_completed"
|
||||
const NPC_SPOKE = "npc_spoke"
|
||||
const NPC_INTERACTION_ERROR = "npc_interaction_error"
|
||||
const NPC_CONVERSATION = "npc_conversation"
|
||||
const REMOTE_SKIN_READY = "remote_skin_ready"
|
||||
const REMOTE_SKIN_FAILED = "remote_skin_failed"
|
||||
const PLAYER_HEALTH_CHANGED = "player_health_changed"
|
||||
|
||||
@@ -13,6 +13,16 @@ const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
|
||||
const REQUEST_TIMEOUT: float = 12.0
|
||||
|
||||
var _activeRequests: Array[HTTPRequest] = []
|
||||
var _pendingAuthRetries: Array[Dictionary] = []
|
||||
var _awaitingSessionRefresh: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
call_deferred("_connect_auth_manager")
|
||||
|
||||
func _connect_auth_manager() -> void:
|
||||
var authManager := get_node_or_null("/root/AuthManager")
|
||||
if authManager != null and authManager.has_signal("session_refresh_completed") and not authManager.is_connected("session_refresh_completed", _on_session_refresh_completed):
|
||||
authManager.connect("session_refresh_completed", _on_session_refresh_completed)
|
||||
|
||||
func _exit_tree() -> void:
|
||||
for request in _activeRequests:
|
||||
@@ -20,6 +30,7 @@ func _exit_tree() -> void:
|
||||
request.cancel_request()
|
||||
request.queue_free()
|
||||
_activeRequests.clear()
|
||||
_pendingAuthRetries.clear()
|
||||
|
||||
func get_json(endpoint: String, callback: Callable, authenticated: bool = true) -> void:
|
||||
request_json(endpoint, {}, callback, HTTPClient.METHOD_GET, authenticated)
|
||||
@@ -34,6 +45,9 @@ func put_json(endpoint: String, payload: Dictionary, callback: Callable, authent
|
||||
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:
|
||||
_request_json(endpoint, payload, callback, method, authenticated, 0)
|
||||
|
||||
func _request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int, authenticated: bool, authRetryCount: int) -> void:
|
||||
var request := HTTPRequest.new()
|
||||
request.timeout = REQUEST_TIMEOUT
|
||||
add_child(request)
|
||||
@@ -42,7 +56,7 @@ func request_json(endpoint: String, payload: Dictionary, callback: Callable, met
|
||||
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)
|
||||
_handle_response(endpoint, result, responseCode, body, callback, payload, method, authenticated, authRetryCount)
|
||||
)
|
||||
|
||||
var url := "%s%s" % [NetworkConfig.get_api_base_url(), endpoint]
|
||||
@@ -64,13 +78,27 @@ func _headers(authenticated: bool) -> PackedStringArray:
|
||||
headers.append("Authorization: Bearer %s" % accessToken)
|
||||
return headers
|
||||
|
||||
func _handle_response(endpoint: String, result: int, responseCode: int, body: PackedByteArray, callback: Callable) -> void:
|
||||
func _handle_response(
|
||||
endpoint: String,
|
||||
result: int,
|
||||
responseCode: int,
|
||||
body: PackedByteArray,
|
||||
callback: Callable,
|
||||
payload: Dictionary,
|
||||
method: int,
|
||||
authenticated: bool,
|
||||
authRetryCount: int
|
||||
) -> 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
|
||||
|
||||
if responseCode == 401 and authenticated and authRetryCount < 1:
|
||||
_queue_authenticated_retry(endpoint, payload, callback, method, authRetryCount + 1)
|
||||
return
|
||||
|
||||
var bodyText := body.get_string_from_utf8()
|
||||
var json := JSON.new()
|
||||
if json.parse(bodyText) != OK:
|
||||
@@ -100,6 +128,63 @@ func _handle_response(endpoint: String, result: int, responseCode: int, body: Pa
|
||||
request_failed.emit(endpoint, str(errorInfo.get("message", "请求失败")))
|
||||
callback.call(false, response, errorInfo)
|
||||
|
||||
func _queue_authenticated_retry(endpoint: String, payload: Dictionary, callback: Callable, method: int, authRetryCount: int) -> void:
|
||||
_pendingAuthRetries.append({
|
||||
"endpoint": endpoint,
|
||||
"payload": payload.duplicate(true),
|
||||
"callback": callback,
|
||||
"method": method,
|
||||
"auth_retry_count": authRetryCount,
|
||||
"account_generation": _current_account_generation(),
|
||||
})
|
||||
if _awaitingSessionRefresh:
|
||||
return
|
||||
|
||||
var authManager := get_node_or_null("/root/AuthManager")
|
||||
if authManager == null or not authManager.has_method("refresh_session"):
|
||||
_finish_authenticated_retries(false)
|
||||
return
|
||||
_awaitingSessionRefresh = true
|
||||
if not bool(authManager.call("refresh_session")):
|
||||
_finish_authenticated_retries(false)
|
||||
|
||||
func _on_session_refresh_completed(success: bool) -> void:
|
||||
_finish_authenticated_retries(success)
|
||||
|
||||
func _finish_authenticated_retries(success: bool) -> void:
|
||||
var retries := _pendingAuthRetries.duplicate()
|
||||
_pendingAuthRetries.clear()
|
||||
_awaitingSessionRefresh = false
|
||||
var currentGeneration := _current_account_generation()
|
||||
for retryVariant in retries:
|
||||
if not (retryVariant is Dictionary):
|
||||
continue
|
||||
var retry: Dictionary = retryVariant
|
||||
var callback: Callable = retry.get("callback", Callable())
|
||||
if not callback.is_valid():
|
||||
continue
|
||||
if not success or int(retry.get("account_generation", -1)) != currentGeneration:
|
||||
callback.call(false, {}, {
|
||||
"message": "登录状态已过期,请重新登录",
|
||||
"response_code": 401,
|
||||
"error_code": "SESSION_EXPIRED",
|
||||
})
|
||||
continue
|
||||
_request_json(
|
||||
str(retry.get("endpoint", "")),
|
||||
retry.get("payload", {}) as Dictionary,
|
||||
callback,
|
||||
int(retry.get("method", HTTPClient.METHOD_GET)),
|
||||
true,
|
||||
int(retry.get("auth_retry_count", 1))
|
||||
)
|
||||
|
||||
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 _http_result_to_string(result: int) -> String:
|
||||
match result:
|
||||
HTTPRequest.RESULT_CHUNKED_BODY_SIZE_MISMATCH:
|
||||
|
||||
@@ -21,6 +21,7 @@ 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 AVATAR_MASK_SHADER = preload("res://assets/shaders/avatar_round_mask.gdshader")
|
||||
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
|
||||
|
||||
const SKINS: Array[Dictionary] = [
|
||||
@@ -99,9 +100,12 @@ var _remoteSkinRetryCounts: Dictionary = {}
|
||||
var _accountAvatarUrl: String = ""
|
||||
var _accountAvatarTexture: Texture2D
|
||||
var _profileSyncSuppressed: bool = false
|
||||
var _confirmedSkinId: String = FALLBACK_SKIN_ID
|
||||
var _pendingSkinId: String = ""
|
||||
|
||||
func _ready() -> void:
|
||||
_load()
|
||||
_confirmedSkinId = selectedSkinId
|
||||
_connect_auth_events()
|
||||
_emit_profile_changed()
|
||||
|
||||
@@ -226,12 +230,17 @@ func set_selected_skin(skinId: String, allowLocked: bool = false) -> bool:
|
||||
if normalizedId == selectedSkinId:
|
||||
return true
|
||||
|
||||
var shouldSync := not _profileSyncSuppressed and _is_account_authenticated()
|
||||
if shouldSync:
|
||||
_pendingSkinId = normalizedId
|
||||
selectedSkinId = normalizedId
|
||||
_save()
|
||||
_emit_skin_changed()
|
||||
_emit_profile_changed()
|
||||
if not _profileSyncSuppressed:
|
||||
if shouldSync:
|
||||
_sync_account_skin_profile()
|
||||
else:
|
||||
_confirmedSkinId = normalizedId
|
||||
return true
|
||||
|
||||
func apply_account_profile(profile: Dictionary) -> void:
|
||||
@@ -240,23 +249,28 @@ func apply_account_profile(profile: Dictionary) -> void:
|
||||
_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())
|
||||
_accountAvatarTexture = _texture_from_base64(str(profile.get("avatar_base64", "")).strip_edges(), true)
|
||||
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():
|
||||
var shouldApplySkin := _pendingSkinId.is_empty() or skinId == _pendingSkinId
|
||||
if not skinId.is_empty() and shouldApplySkin:
|
||||
grant_owned_skin(skinId)
|
||||
set_selected_skin(skinId, true)
|
||||
else:
|
||||
_confirmedSkinId = skinId
|
||||
if skinId == _pendingSkinId:
|
||||
_pendingSkinId = ""
|
||||
elif skinId.is_empty() and _pendingSkinId.is_empty():
|
||||
var fallbackSkinId := _first_owned_skin_id()
|
||||
if selectedSkinId != fallbackSkinId:
|
||||
selectedSkinId = fallbackSkinId
|
||||
_save()
|
||||
_emit_skin_changed()
|
||||
_emit_profile_changed()
|
||||
_confirmedSkinId = fallbackSkinId
|
||||
_profileSyncSuppressed = false
|
||||
if _accountAvatarTexture != null or not _accountAvatarUrl.is_empty():
|
||||
selectedAvatarId = CUSTOM_AVATAR_ID
|
||||
@@ -306,7 +320,7 @@ func apply_account_avatar_base64(avatarUrl: String, avatarBase64: String) -> voi
|
||||
_accountAvatarUrl = _absolute_backend_url(avatarUrl.strip_edges())
|
||||
_customAvatarTexture = null
|
||||
_customAvatarActive = false
|
||||
_accountAvatarTexture = _texture_from_base64(avatarBase64)
|
||||
_accountAvatarTexture = _texture_from_base64(avatarBase64, true)
|
||||
if _accountAvatarTexture != null or not _accountAvatarUrl.is_empty():
|
||||
selectedAvatarId = CUSTOM_AVATAR_ID
|
||||
else:
|
||||
@@ -715,11 +729,45 @@ func _sync_account_skin_profile() -> void:
|
||||
|
||||
func _connect_auth_events() -> void:
|
||||
var eventSystem := get_node_or_null("/root/EventSystem")
|
||||
if eventSystem == null:
|
||||
if eventSystem != null:
|
||||
eventSystem.call("connect_event", EventNames.AUTH_LOGOUT, _on_auth_logout, self)
|
||||
var authManager := get_node_or_null("/root/AuthManager")
|
||||
if authManager != null and authManager.has_signal("appearance_update_succeeded"):
|
||||
authManager.connect("appearance_update_succeeded", _on_appearance_update_succeeded)
|
||||
if authManager != null and authManager.has_signal("appearance_update_failed"):
|
||||
authManager.connect("appearance_update_failed", _on_appearance_update_failed)
|
||||
|
||||
func _on_appearance_update_succeeded(requestedSkinId: String, profile: Dictionary) -> void:
|
||||
if requestedSkinId != _pendingSkinId:
|
||||
return
|
||||
eventSystem.call("connect_event", EventNames.AUTH_LOGOUT, _on_auth_logout, self)
|
||||
var confirmedSkinId := str(profile.get("skin_id", requestedSkinId)).strip_edges()
|
||||
if confirmedSkinId.is_empty():
|
||||
confirmedSkinId = requestedSkinId
|
||||
_pendingSkinId = ""
|
||||
_confirmedSkinId = confirmedSkinId
|
||||
if selectedSkinId != confirmedSkinId:
|
||||
_apply_selected_skin_locally(confirmedSkinId)
|
||||
|
||||
func _on_appearance_update_failed(requestedSkinId: String, _message: String) -> void:
|
||||
if requestedSkinId != _pendingSkinId:
|
||||
return
|
||||
_pendingSkinId = ""
|
||||
if not _confirmedSkinId.is_empty() and selectedSkinId == requestedSkinId:
|
||||
_apply_selected_skin_locally(_confirmedSkinId)
|
||||
|
||||
func _apply_selected_skin_locally(skinId: String) -> void:
|
||||
selectedSkinId = skinId
|
||||
_save()
|
||||
_emit_skin_changed()
|
||||
_emit_profile_changed()
|
||||
|
||||
func _is_account_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 _on_auth_logout(_data: Variant = null) -> void:
|
||||
_pendingSkinId = ""
|
||||
_confirmedSkinId = FALLBACK_SKIN_ID
|
||||
clear_account_profile()
|
||||
|
||||
func get_profile_payload() -> Dictionary:
|
||||
@@ -762,11 +810,19 @@ func _get_or_create_avatar_texture_rect(panel: PanelContainer) -> TextureRect:
|
||||
textureRect.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
|
||||
textureRect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
textureRect.stretch_mode = TextureRect.STRETCH_SCALE
|
||||
textureRect.material = _create_avatar_mask_material()
|
||||
textureRect.custom_minimum_size = panel.custom_minimum_size
|
||||
textureRect.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
panel.add_child(textureRect)
|
||||
return textureRect
|
||||
|
||||
func _create_avatar_mask_material() -> ShaderMaterial:
|
||||
var material := ShaderMaterial.new()
|
||||
material.shader = AVATAR_MASK_SHADER
|
||||
material.set_shader_parameter("corner_radius", 0.22)
|
||||
material.set_shader_parameter("edge_feather", 0.008)
|
||||
return material
|
||||
|
||||
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()
|
||||
@@ -784,7 +840,7 @@ func _normalize_account_skin_asset(skin: Dictionary) -> Dictionary:
|
||||
normalized["account_asset"] = true
|
||||
return normalized
|
||||
|
||||
func _texture_from_base64(base64Text: String) -> Texture2D:
|
||||
func _texture_from_base64(base64Text: String, roundAvatar: bool = false) -> Texture2D:
|
||||
var bytes := Marshalls.base64_to_raw(base64Text.strip_edges())
|
||||
if bytes.is_empty():
|
||||
return null
|
||||
@@ -792,6 +848,9 @@ func _texture_from_base64(base64Text: String) -> Texture2D:
|
||||
var err := image.load_png_from_buffer(bytes)
|
||||
if err != OK:
|
||||
return null
|
||||
if roundAvatar:
|
||||
image = _create_square_avatar_image(image)
|
||||
_apply_rounded_rect_alpha(image)
|
||||
return ImageTexture.create_from_image(image)
|
||||
|
||||
func _absolute_backend_url(path: String) -> String:
|
||||
|
||||
@@ -16,6 +16,9 @@ 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 appearance_update_succeeded(skin_id: String, profile: Dictionary)
|
||||
signal appearance_update_failed(skin_id: String, message: String)
|
||||
signal session_refresh_completed(success: bool)
|
||||
signal logout_completed()
|
||||
signal browser_bootstrap_received(kind: String)
|
||||
|
||||
@@ -25,6 +28,8 @@ const DEFAULT_AUTH_CONFIG_PATH: String = "user://auth.cfg"
|
||||
const REQUEST_TIMEOUT: float = 12.0
|
||||
const BROWSER_BOOTSTRAP_STORAGE_KEY: String = "whaletown.auth.bootstrap"
|
||||
const BROWSER_BOOTSTRAP_POLL_INTERVAL: float = 0.25
|
||||
const SESSION_REFRESH_CHECK_INTERVAL: float = 15.0
|
||||
const SESSION_REFRESH_LEAD_TIME: float = 120.0
|
||||
|
||||
var _access_token: String = ""
|
||||
var _refresh_token: String = ""
|
||||
@@ -41,19 +46,27 @@ var _registration_recovery_in_flight: bool = false
|
||||
var _registration_recovery_error: String = ""
|
||||
var _browser_bootstrap_kind: String = ""
|
||||
var _browser_bootstrap_poll_elapsed: float = 0.0
|
||||
var _session_refresh_check_elapsed: float = 0.0
|
||||
var _access_token_expires_at: float = 0.0
|
||||
|
||||
func _ready() -> void:
|
||||
_load_cached_session()
|
||||
_try_import_browser_bootstrap(false)
|
||||
if is_authenticated() and _token_needs_refresh():
|
||||
call_deferred("refresh_session")
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if OS.get_name() != "Web" or not _browser_bootstrap_kind.is_empty():
|
||||
return
|
||||
_browser_bootstrap_poll_elapsed += delta
|
||||
if _browser_bootstrap_poll_elapsed < BROWSER_BOOTSTRAP_POLL_INTERVAL:
|
||||
return
|
||||
_browser_bootstrap_poll_elapsed = 0.0
|
||||
_try_import_browser_bootstrap(true)
|
||||
_session_refresh_check_elapsed += delta
|
||||
if _session_refresh_check_elapsed >= SESSION_REFRESH_CHECK_INTERVAL:
|
||||
_session_refresh_check_elapsed = 0.0
|
||||
if is_authenticated() and _token_needs_refresh():
|
||||
refresh_session()
|
||||
|
||||
if OS.get_name() == "Web" and _browser_bootstrap_kind.is_empty():
|
||||
_browser_bootstrap_poll_elapsed += delta
|
||||
if _browser_bootstrap_poll_elapsed >= BROWSER_BOOTSTRAP_POLL_INTERVAL:
|
||||
_browser_bootstrap_poll_elapsed = 0.0
|
||||
_try_import_browser_bootstrap(true)
|
||||
|
||||
func _exit_tree() -> void:
|
||||
for request in _active_requests:
|
||||
@@ -112,8 +125,12 @@ func login(identifier: String, password: String) -> void:
|
||||
"password": password
|
||||
}, _on_login_response, HTTPClient.METHOD_POST, false, true)
|
||||
|
||||
func send_email_verification(email: String) -> void:
|
||||
func send_email_verification(email: String, invitation_code: String = "") -> void:
|
||||
var normalized_email := email.strip_edges()
|
||||
var normalized_invitation_code := invitation_code.strip_edges().to_upper()
|
||||
if normalized_invitation_code.is_empty():
|
||||
email_verification_failed.emit("请输入邀请码")
|
||||
return
|
||||
if normalized_email.is_empty():
|
||||
email_verification_failed.emit("请输入邮箱")
|
||||
return
|
||||
@@ -122,7 +139,8 @@ func send_email_verification(email: String) -> void:
|
||||
return
|
||||
|
||||
_request_json("/auth/send-email-verification", {
|
||||
"email": normalized_email
|
||||
"email": normalized_email,
|
||||
"invitation_code": normalized_invitation_code
|
||||
}, func(success: bool, _data: Dictionary, error_info: Dictionary) -> void:
|
||||
if success:
|
||||
email_verification_sent.emit(normalized_email)
|
||||
@@ -130,11 +148,12 @@ func send_email_verification(email: String) -> void:
|
||||
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:
|
||||
func register(username: String, password: String, nickname: String = "", email: String = "", email_verification_code: String = "", skin_id: String = "", invitation_code: 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()
|
||||
var normalized_invitation_code := invitation_code.strip_edges().to_upper()
|
||||
|
||||
if normalized_username.is_empty():
|
||||
register_failed.emit("请输入用户名")
|
||||
@@ -142,6 +161,9 @@ func register(username: String, password: String, nickname: String = "", email:
|
||||
if not _is_valid_username(normalized_username):
|
||||
register_failed.emit("用户名只能包含字母、数字和下划线,长度 1-50")
|
||||
return
|
||||
if normalized_invitation_code.is_empty():
|
||||
register_failed.emit("请输入邀请码")
|
||||
return
|
||||
if password.length() < 8:
|
||||
register_failed.emit("密码至少 8 位,并需要包含字母和数字")
|
||||
return
|
||||
@@ -165,7 +187,8 @@ func register(username: String, password: String, nickname: String = "", email:
|
||||
"password": password,
|
||||
"nickname": normalized_nickname,
|
||||
"email": normalized_email,
|
||||
"email_verification_code": normalized_code
|
||||
"email_verification_code": normalized_code,
|
||||
"invitation_code": normalized_invitation_code
|
||||
}
|
||||
var normalized_skin_id := skin_id.strip_edges()
|
||||
if not normalized_skin_id.is_empty():
|
||||
@@ -197,10 +220,11 @@ func update_profile(profile_data: Dictionary) -> void:
|
||||
assetPayload.erase("skin_id")
|
||||
assetPayload.erase("settings")
|
||||
if profile_data.has("skin_id") and playerStateManager.has_method("update_appearance"):
|
||||
var requestedSkinId := str(profile_data.get("skin_id", "")).strip_edges()
|
||||
playerStateManager.call(
|
||||
"update_appearance",
|
||||
str(profile_data.get("skin_id", "")),
|
||||
Callable(self, "_on_profile_response")
|
||||
requestedSkinId,
|
||||
Callable(self, "_on_appearance_profile_response").bind(requestedSkinId)
|
||||
)
|
||||
if profile_data.has("settings") and playerStateManager.has_method("update_settings"):
|
||||
var settingsVariant: Variant = profile_data.get("settings", {})
|
||||
@@ -227,14 +251,17 @@ func update_profile(profile_data: Dictionary) -> void:
|
||||
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
|
||||
func refresh_session() -> bool:
|
||||
if _refresh_token.strip_edges().is_empty():
|
||||
return false
|
||||
if _refresh_in_flight:
|
||||
return true
|
||||
|
||||
_refresh_in_flight = true
|
||||
_request_json("/auth/refresh-token", {
|
||||
"refresh_token": _refresh_token
|
||||
}, _on_refresh_response, HTTPClient.METHOD_POST, false, true)
|
||||
return true
|
||||
|
||||
func logout() -> void:
|
||||
_clear_session_memory()
|
||||
@@ -388,10 +415,12 @@ func _clear_pending_registration() -> void:
|
||||
func _on_refresh_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void:
|
||||
_refresh_in_flight = false
|
||||
if not success:
|
||||
session_refresh_completed.emit(false)
|
||||
logout()
|
||||
return
|
||||
|
||||
_apply_auth_payload(data)
|
||||
session_refresh_completed.emit(true)
|
||||
auth_state_changed.emit(true, get_current_user())
|
||||
_refresh_player_snapshot()
|
||||
|
||||
@@ -403,6 +432,11 @@ func _apply_auth_payload(payload: Dictionary) -> void:
|
||||
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()
|
||||
_access_token_expires_at = _jwt_expiration_time(_access_token)
|
||||
if _access_token_expires_at <= 0.0:
|
||||
var expiresIn := float(auth_data.get("expires_in", 0.0))
|
||||
if expiresIn > 0.0:
|
||||
_access_token_expires_at = Time.get_unix_time_from_system() + expiresIn
|
||||
|
||||
var user_variant: Variant = auth_data.get("user", {})
|
||||
if user_variant is Dictionary:
|
||||
@@ -466,6 +500,36 @@ func _on_profile_response(success: bool, data: Dictionary, _error_info: Dictiona
|
||||
profile_update_succeeded.emit(get_current_profile())
|
||||
auth_state_changed.emit(is_authenticated(), get_current_user())
|
||||
|
||||
func _on_appearance_profile_response(
|
||||
success: bool,
|
||||
data: Dictionary,
|
||||
errorInfo: Dictionary,
|
||||
requestedSkinId: String
|
||||
) -> void:
|
||||
if not success:
|
||||
var message := str(errorInfo.get("message", "玩家资料保存失败"))
|
||||
appearance_update_failed.emit(requestedSkinId, message)
|
||||
profile_update_failed.emit(message)
|
||||
auth_state_changed.emit(is_authenticated(), get_current_user())
|
||||
return
|
||||
|
||||
var dataVariant: Variant = data.get("data", {})
|
||||
if not (dataVariant is Dictionary):
|
||||
var message := "玩家状态响应格式错误"
|
||||
appearance_update_failed.emit(requestedSkinId, message)
|
||||
profile_update_failed.emit(message)
|
||||
auth_state_changed.emit(is_authenticated(), get_current_user())
|
||||
return
|
||||
var snapshot := dataVariant 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)
|
||||
var profile := get_current_profile()
|
||||
appearance_update_succeeded.emit(requestedSkinId, profile)
|
||||
profile_update_succeeded.emit(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
|
||||
@@ -539,6 +603,7 @@ func _load_cached_session(emit_cached_state: bool = true) -> void:
|
||||
|
||||
_refresh_token = str(config.get_value("auth", "refresh_token", "")).strip_edges()
|
||||
_access_token = str(config.get_value("auth", "access_token", "")).strip_edges()
|
||||
_access_token_expires_at = _jwt_expiration_time(_access_token)
|
||||
_current_user.clear()
|
||||
_current_profile.clear()
|
||||
|
||||
@@ -554,6 +619,7 @@ func _emit_cached_auth_state() -> void:
|
||||
func _clear_session_memory() -> void:
|
||||
_access_token = ""
|
||||
_refresh_token = ""
|
||||
_access_token_expires_at = 0.0
|
||||
_current_user.clear()
|
||||
_current_profile.clear()
|
||||
_advance_account_generation()
|
||||
@@ -610,6 +676,26 @@ func _password_has_letter_and_number(password: String) -> bool:
|
||||
has_number = true
|
||||
return has_letter and has_number
|
||||
|
||||
func _token_needs_refresh() -> bool:
|
||||
if _refresh_in_flight or _refresh_token.is_empty():
|
||||
return false
|
||||
if _access_token_expires_at <= 0.0:
|
||||
return false
|
||||
return Time.get_unix_time_from_system() + SESSION_REFRESH_LEAD_TIME >= _access_token_expires_at
|
||||
|
||||
func _jwt_expiration_time(token: String) -> float:
|
||||
var parts := token.split(".")
|
||||
if parts.size() != 3:
|
||||
return 0.0
|
||||
var payload := str(parts[1]).replace("-", "+").replace("_", "/")
|
||||
while payload.length() % 4 != 0:
|
||||
payload += "="
|
||||
var jsonText := Marshalls.base64_to_raw(payload).get_string_from_utf8()
|
||||
var json := JSON.new()
|
||||
if json.parse(jsonText) != OK or not (json.data is Dictionary):
|
||||
return 0.0
|
||||
return float((json.data as Dictionary).get("exp", 0.0))
|
||||
|
||||
func _http_result_to_string(result: int) -> String:
|
||||
match result:
|
||||
HTTPRequest.RESULT_SUCCESS:
|
||||
|
||||
@@ -66,11 +66,13 @@ const CHAT_WEBSOCKET_MANAGER_SCRIPT: Script = preload("res://_Core/managers/WebS
|
||||
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
|
||||
|
||||
# WebSocket 服务器 URL(原生 WebSocket)
|
||||
const WEBSOCKET_URL: String = "wss://whaletownend.xinghangee.icu/game"
|
||||
const WEBSOCKET_URL: String = "wss://whaletown.novamailio.com/game"
|
||||
|
||||
# 重连配置
|
||||
const RECONNECT_MAX_ATTEMPTS: int = 5
|
||||
const RECONNECT_BASE_DELAY: float = 3.0
|
||||
const APPEARANCE_BROADCAST_RETRY_DELAY: float = 1.5
|
||||
const APPEARANCE_BROADCAST_MAX_RETRIES: int = 5
|
||||
|
||||
# 频率限制配置
|
||||
const RATE_LIMIT_MESSAGES: int = 10
|
||||
@@ -98,7 +100,9 @@ const CHAT_ERROR_MESSAGES: Dictionary = {
|
||||
"PERMISSION_DENIED": "您没有权限发送消息",
|
||||
"SESSION_EXPIRED": "会话已过期,请重新连接",
|
||||
"ZULIP_ERROR": "消息服务暂时不可用",
|
||||
"INTERNAL_ERROR": "服务器内部错误"
|
||||
"INTERNAL_ERROR": "服务器内部错误",
|
||||
"INSUFFICIENT_BALANCE": "鲸币余额不足,发布世界公告需要 100 鲸币",
|
||||
"WALLET_UNAVAILABLE": "鲸币服务暂不可用,请稍后再试"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
@@ -125,11 +129,18 @@ var _message_timestamps: Array[float] = []
|
||||
|
||||
# 当前用户信息
|
||||
var _current_username: String = ""
|
||||
var _current_user_id: String = ""
|
||||
var _current_map: String = ""
|
||||
var _world_ready_map: String = ""
|
||||
var _world_ready_confirmed: bool = false
|
||||
var _appearance_broadcast_pending: bool = false
|
||||
var _appearance_broadcast_skin_id: String = ""
|
||||
var _appearance_broadcast_retry_count: int = 0
|
||||
var _appearance_broadcast_retry_timer: Timer
|
||||
|
||||
# 游戏 token
|
||||
var _game_token: String = ""
|
||||
var _guest_mode: bool = false
|
||||
|
||||
# 连接/登录完成前暂存用户已经提交的消息,避免刚进游戏时第一条消息被丢掉。
|
||||
var _queued_messages: Array[Dictionary] = []
|
||||
@@ -164,6 +175,10 @@ func _ready() -> void:
|
||||
_history_request.timeout = 12.0
|
||||
_history_request.request_completed.connect(_on_history_request_completed)
|
||||
add_child(_history_request)
|
||||
_appearance_broadcast_retry_timer = Timer.new()
|
||||
_appearance_broadcast_retry_timer.one_shot = true
|
||||
_appearance_broadcast_retry_timer.timeout.connect(_on_appearance_broadcast_retry_timeout)
|
||||
add_child(_appearance_broadcast_retry_timer)
|
||||
|
||||
# 连接信号
|
||||
_connect_signals()
|
||||
@@ -209,13 +224,24 @@ func set_game_token(token: String) -> void:
|
||||
_queued_messages.clear()
|
||||
_recoverable_messages.clear()
|
||||
_world_ready_map = ""
|
||||
_world_ready_confirmed = false
|
||||
_clear_pending_appearance_broadcast()
|
||||
|
||||
func is_logged_in() -> bool:
|
||||
return _is_logged_in
|
||||
|
||||
func mark_world_ready(mapId: String, position: Vector2) -> void:
|
||||
func is_guest_mode() -> bool:
|
||||
return _guest_mode
|
||||
|
||||
func start_guest_session() -> void:
|
||||
_guest_mode = true
|
||||
_game_token = ""
|
||||
_is_logged_in = false
|
||||
connect_to_chat_server()
|
||||
|
||||
func mark_world_ready(mapId: String, position: Vector2, direction: String = "down", movementState: String = "idle", sequence: int = 0) -> void:
|
||||
var normalizedMapId := mapId.strip_edges()
|
||||
if not _is_logged_in or normalizedMapId.is_empty() or _world_ready_map == normalizedMapId:
|
||||
if not _is_logged_in or normalizedMapId.is_empty() or (_world_ready_map == normalizedMapId and _world_ready_confirmed):
|
||||
return
|
||||
|
||||
var payload := {
|
||||
@@ -223,9 +249,13 @@ func mark_world_ready(mapId: String, position: Vector2) -> void:
|
||||
"mapId": normalizedMapId,
|
||||
"x": position.x,
|
||||
"y": position.y,
|
||||
"direction": direction,
|
||||
"movementState": movementState,
|
||||
"sequence": sequence,
|
||||
}
|
||||
if _websocket_manager.send_message(JSON.stringify(payload)) == OK:
|
||||
_world_ready_map = normalizedMapId
|
||||
_world_ready_confirmed = false
|
||||
|
||||
func leave_world(sceneId: String = "private_space") -> void:
|
||||
if not _is_logged_in or _world_ready_map.is_empty():
|
||||
@@ -239,11 +269,60 @@ func leave_world(sceneId: String = "private_space") -> void:
|
||||
}
|
||||
if _websocket_manager.send_message(JSON.stringify(payload)) == OK:
|
||||
_world_ready_map = ""
|
||||
_world_ready_confirmed = false
|
||||
_stop_appearance_broadcast_retry()
|
||||
|
||||
func notify_appearance_changed() -> void:
|
||||
if not _is_logged_in or _world_ready_map.is_empty():
|
||||
func notify_appearance_changed(skinId: String = "") -> void:
|
||||
var normalizedSkinId := skinId.strip_edges()
|
||||
if not normalizedSkinId.is_empty():
|
||||
_appearance_broadcast_skin_id = normalizedSkinId
|
||||
_appearance_broadcast_pending = true
|
||||
_appearance_broadcast_retry_count = 0
|
||||
_try_send_pending_appearance_broadcast()
|
||||
|
||||
func _try_send_pending_appearance_broadcast() -> void:
|
||||
if not _appearance_broadcast_pending or not _is_logged_in or not _world_ready_confirmed:
|
||||
return
|
||||
if _world_ready_map.is_empty() or _appearance_broadcast_retry_count >= APPEARANCE_BROADCAST_MAX_RETRIES:
|
||||
return
|
||||
_appearance_broadcast_retry_count += 1
|
||||
_websocket_manager.send_message(JSON.stringify({"type": "appearance_changed"}))
|
||||
_schedule_appearance_broadcast_retry()
|
||||
|
||||
func _schedule_appearance_broadcast_retry() -> void:
|
||||
if _appearance_broadcast_retry_count >= APPEARANCE_BROADCAST_MAX_RETRIES:
|
||||
return
|
||||
if is_instance_valid(_appearance_broadcast_retry_timer):
|
||||
_appearance_broadcast_retry_timer.start(APPEARANCE_BROADCAST_RETRY_DELAY)
|
||||
|
||||
func _stop_appearance_broadcast_retry() -> void:
|
||||
if is_instance_valid(_appearance_broadcast_retry_timer):
|
||||
_appearance_broadcast_retry_timer.stop()
|
||||
|
||||
func _clear_pending_appearance_broadcast() -> void:
|
||||
_appearance_broadcast_pending = false
|
||||
_appearance_broadcast_skin_id = ""
|
||||
_appearance_broadcast_retry_count = 0
|
||||
_stop_appearance_broadcast_retry()
|
||||
|
||||
func _on_appearance_broadcast_retry_timeout() -> void:
|
||||
_try_send_pending_appearance_broadcast()
|
||||
|
||||
func _handle_world_ready_success(data: Dictionary) -> void:
|
||||
var confirmedMapId := str(data.get("mapId", data.get("map_id", ""))).strip_edges()
|
||||
if not confirmedMapId.is_empty():
|
||||
_world_ready_map = confirmedMapId
|
||||
_world_ready_confirmed = not _world_ready_map.is_empty()
|
||||
if _appearance_broadcast_pending:
|
||||
_appearance_broadcast_retry_count = 0
|
||||
_try_send_pending_appearance_broadcast()
|
||||
|
||||
func _handle_appearance_changed_success(data: Dictionary) -> void:
|
||||
var confirmedSkinId := str(data.get("skinId", data.get("skin_id", ""))).strip_edges()
|
||||
if not _appearance_broadcast_skin_id.is_empty() and confirmedSkinId != _appearance_broadcast_skin_id:
|
||||
_schedule_appearance_broadcast_retry()
|
||||
return
|
||||
_clear_pending_appearance_broadcast()
|
||||
|
||||
# 获取游戏 token
|
||||
#
|
||||
@@ -267,12 +346,12 @@ func connect_to_chat_server() -> void:
|
||||
push_warning("聊天服务器正在连接")
|
||||
return
|
||||
|
||||
if _game_token.strip_edges().is_empty():
|
||||
if not _guest_mode and _game_token.strip_edges().is_empty():
|
||||
var authToken := _get_auth_manager_token()
|
||||
if not authToken.is_empty():
|
||||
_game_token = authToken
|
||||
|
||||
if _game_token.strip_edges().is_empty():
|
||||
if not _guest_mode and _game_token.strip_edges().is_empty():
|
||||
_handle_error("AUTH_FAILED", "请先登录后再使用聊天")
|
||||
return
|
||||
|
||||
@@ -291,6 +370,7 @@ func disconnect_from_chat_server() -> void:
|
||||
|
||||
# 断开连接
|
||||
_websocket_manager.disconnect_websocket()
|
||||
_guest_mode = false
|
||||
|
||||
# 检查是否已连接
|
||||
#
|
||||
@@ -325,6 +405,49 @@ func can_attempt_chat_connection() -> bool:
|
||||
func send_chat_message(content: String, scope: String = "local", show_bubble: bool = false) -> bool:
|
||||
return _send_chat_payload(content, scope, {"bubble": show_bubble and _settings_bool("show_chat_bubbles", true)})
|
||||
|
||||
## Publish a paid world announcement. The server owns validation and charging.
|
||||
func send_world_bulletin(content: String) -> bool:
|
||||
if not _is_logged_in:
|
||||
_handle_error("AUTH_FAILED", "请先登录后再发布世界公告")
|
||||
return false
|
||||
# 客户端只表达发布意图;价格、实时余额校验和扣费全由服务端处理。
|
||||
return _send_chat_payload(content, "global", {
|
||||
"bubble": false,
|
||||
"worldBulletin": true,
|
||||
"_suppressLocalEcho": true,
|
||||
"_nonRecoverable": true
|
||||
})
|
||||
|
||||
func interact_with_world_npc(npc_id: String, message: String = "", session_id: String = "") -> bool:
|
||||
var normalized_npc_id := npc_id.strip_edges()
|
||||
if normalized_npc_id.is_empty() or not _is_logged_in:
|
||||
return false
|
||||
if not _websocket_manager.is_websocket_connected():
|
||||
connect_to_chat_server()
|
||||
return false
|
||||
var payload := {
|
||||
"type": "npc_interact",
|
||||
"npcId": normalized_npc_id,
|
||||
"message": message.strip_edges().left(300),
|
||||
}
|
||||
if not session_id.strip_edges().is_empty():
|
||||
payload["sessionId"] = session_id.strip_edges()
|
||||
var send_err: Error = _websocket_manager.send_message(JSON.stringify(payload))
|
||||
if send_err != OK:
|
||||
_handle_error("SEND_FAILED", "WebSocket send failed: %s" % error_string(send_err))
|
||||
return false
|
||||
return true
|
||||
|
||||
func end_world_npc_session(npc_id: String, session_id: String = "") -> bool:
|
||||
if npc_id.strip_edges().is_empty() or not _is_logged_in or not _websocket_manager.is_websocket_connected():
|
||||
return false
|
||||
return _websocket_manager.send_message(JSON.stringify({
|
||||
"type": "npc_session_end", "npcId": npc_id.strip_edges(), "sessionId": session_id.strip_edges(),
|
||||
})) == OK
|
||||
|
||||
func get_current_user_id() -> String:
|
||||
return _current_user_id
|
||||
|
||||
func send_private_message(content: String, target_user_id: String, target_username: String = "", private_context: String = "") -> bool:
|
||||
if not _settings_bool("allow_nearby_private", true) and private_context.strip_edges() == "whisper":
|
||||
_handle_error("PRIVATE_DISABLED", "附近私聊已在设置中关闭")
|
||||
@@ -449,6 +572,7 @@ func _send_chat_payload(content: String, scope: String = "local", extra_data: Di
|
||||
var show_bubble: bool = bool(extra_data.get("bubble", extra_data.get("showBubble", extra_data.get("show_bubble", false)))) and _settings_bool("show_chat_bubbles", true)
|
||||
var suppress_local_echo := bool(extra_data.get("_suppressLocalEcho", false))
|
||||
var is_recovery_retry := bool(extra_data.get("_isRecoveryRetry", false))
|
||||
var non_recoverable := bool(extra_data.get("_nonRecoverable", false))
|
||||
var recoverable_extra_data := extra_data.duplicate(true)
|
||||
recoverable_extra_data.erase("_suppressLocalEcho")
|
||||
|
||||
@@ -501,11 +625,12 @@ func _send_chat_payload(content: String, scope: String = "local", extra_data: Di
|
||||
if send_err != OK:
|
||||
_handle_error("SEND_FAILED", "WebSocket send failed: %s" % error_string(send_err))
|
||||
return false
|
||||
_track_recoverable_message({
|
||||
"content": normalized_content,
|
||||
"scope": scope,
|
||||
"extra_data": recoverable_extra_data
|
||||
})
|
||||
if not non_recoverable:
|
||||
_track_recoverable_message({
|
||||
"content": normalized_content,
|
||||
"scope": scope,
|
||||
"extra_data": recoverable_extra_data
|
||||
})
|
||||
|
||||
# 记录发送时间
|
||||
if not is_recovery_retry:
|
||||
@@ -575,10 +700,12 @@ func _on_chat_message_sent(_request_id: String, success: bool, data: Dictionary,
|
||||
#
|
||||
# 使用示例:
|
||||
# ChatManager.update_player_position(150.0, 200.0, "novice_village")
|
||||
func update_player_position(x: float, y: float, map_id: String) -> void:
|
||||
update_player_position_with_appearance(x, y, map_id, {})
|
||||
func update_player_position(x: float, y: float, map_id: String, direction: String = "down", movementState: String = "walk", sequence: int = 0) -> void:
|
||||
update_player_position_with_appearance(x, y, map_id, {}, direction, movementState, sequence)
|
||||
|
||||
func update_player_position_with_appearance(x: float, y: float, map_id: String, _appearance: Dictionary = {}) -> void:
|
||||
func update_player_position_with_appearance(x: float, y: float, map_id: String, _appearance: Dictionary = {}, direction: String = "down", movementState: String = "walk", sequence: int = 0) -> void:
|
||||
if _guest_mode:
|
||||
return
|
||||
if not _websocket_manager.is_websocket_connected():
|
||||
connect_to_chat_server()
|
||||
return
|
||||
@@ -589,7 +716,10 @@ func update_player_position_with_appearance(x: float, y: float, map_id: String,
|
||||
"type": "position",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"mapId": map_id
|
||||
"mapId": map_id,
|
||||
"direction": direction,
|
||||
"movementState": movementState,
|
||||
"sequence": sequence
|
||||
}
|
||||
# 发送消息(JSON 字符串)
|
||||
var json_string := JSON.stringify(position_data)
|
||||
@@ -604,6 +734,8 @@ func update_player_position_with_appearance(x: float, y: float, map_id: String,
|
||||
# 返回值:
|
||||
# bool - 是否可以发送
|
||||
func can_send_message() -> bool:
|
||||
if _guest_mode:
|
||||
return false
|
||||
var current_time := Time.get_unix_time_from_system()
|
||||
|
||||
# 清理过期的时间戳
|
||||
@@ -794,6 +926,8 @@ func _connect_signals() -> void:
|
||||
func _on_connection_lost() -> void:
|
||||
_is_logged_in = false
|
||||
_world_ready_map = ""
|
||||
_world_ready_confirmed = false
|
||||
_stop_appearance_broadcast_retry()
|
||||
_restore_recoverable_messages_to_queue()
|
||||
|
||||
func _load_token_from_environment() -> void:
|
||||
@@ -911,6 +1045,9 @@ func _preview_text(text: String) -> String:
|
||||
|
||||
# 发送登录消息
|
||||
func _send_login_message() -> void:
|
||||
if _guest_mode:
|
||||
_websocket_manager.send_message(JSON.stringify({"type": "guest_login"}))
|
||||
return
|
||||
if _game_token.strip_edges().is_empty():
|
||||
_handle_error("AUTH_FAILED", "缺少聊天认证 token")
|
||||
return
|
||||
@@ -967,8 +1104,12 @@ func _on_data_received(message: String) -> void:
|
||||
match message_type:
|
||||
"connected":
|
||||
pass
|
||||
"pong":
|
||||
pass
|
||||
"login_success":
|
||||
_handle_login_success(data)
|
||||
"guest_login_success":
|
||||
_handle_guest_login_success(data)
|
||||
"login_error":
|
||||
_handle_login_error(data)
|
||||
"chat":
|
||||
@@ -997,6 +1138,50 @@ func _on_data_received(message: String) -> void:
|
||||
_handle_friend_error(data)
|
||||
"map_players_snapshot":
|
||||
_handle_map_players_snapshot(data)
|
||||
"npc_snapshot":
|
||||
_handle_npc_snapshot(data)
|
||||
"npc_action_started":
|
||||
_emit_event(EventNames.NPC_ACTION_STARTED, _normalize_npc_action_event(data))
|
||||
"npc_action_completed":
|
||||
_emit_event(EventNames.NPC_ACTION_COMPLETED, _normalize_npc_action_event(data))
|
||||
"npc_spoke":
|
||||
_emit_event(EventNames.NPC_SPOKE, {
|
||||
"npc_id": str(data.get("npcId", data.get("npc_id", ""))),
|
||||
"npc_name": str(data.get("npcName", data.get("npc_name", "NPC"))),
|
||||
"response": str(data.get("response", "")),
|
||||
"public_intention": str(data.get("publicIntention", data.get("public_intention", ""))),
|
||||
"activity": data.get("activity", {}),
|
||||
"memory_id": str(data.get("memoryId", data.get("memory_id", ""))),
|
||||
"target_user_id": str(data.get("targetUserId", data.get("target_user_id", ""))),
|
||||
"target_username": str(data.get("targetUsername", data.get("target_username", ""))),
|
||||
"session_id": str(data.get("sessionId", data.get("session_id", ""))),
|
||||
})
|
||||
"npc_interaction_success":
|
||||
pass
|
||||
"npc_interaction_error":
|
||||
_emit_event(EventNames.NPC_INTERACTION_ERROR, {
|
||||
"npc_id": str(data.get("npcId", data.get("npc_id", ""))),
|
||||
"code": str(data.get("code", "INTERACTION_REJECTED")),
|
||||
"message": str(data.get("message", "NPC暂时无法回应")),
|
||||
})
|
||||
"npc_conversation":
|
||||
var conversationLines: Array[Dictionary] = []
|
||||
var rawLines: Variant = data.get("lines", [])
|
||||
if rawLines is Array:
|
||||
for rawLine in rawLines:
|
||||
if rawLine is Dictionary:
|
||||
conversationLines.append({
|
||||
"speaker_npc_id": str(rawLine.get("speakerNpcId", rawLine.get("speaker_npc_id", ""))),
|
||||
"speaker_name": str(rawLine.get("speakerName", rawLine.get("speaker_name", "NPC"))),
|
||||
"text": str(rawLine.get("text", "")),
|
||||
})
|
||||
_emit_event(EventNames.NPC_CONVERSATION, {
|
||||
"conversation_id": str(data.get("conversationId", data.get("conversation_id", ""))),
|
||||
"encounter_id": str(data.get("encounterId", data.get("encounter_id", ""))),
|
||||
"mapId": str(data.get("mapId", data.get("map_id", ""))),
|
||||
"location_id": str(data.get("locationId", data.get("location_id", ""))),
|
||||
"lines": conversationLines,
|
||||
})
|
||||
"system_presence":
|
||||
_handle_system_presence(data)
|
||||
"position_update":
|
||||
@@ -1006,9 +1191,13 @@ func _on_data_received(message: String) -> void:
|
||||
"appearance_changed":
|
||||
_handle_player_joined(data)
|
||||
"world_ready_success":
|
||||
pass
|
||||
_handle_world_ready_success(data)
|
||||
"appearance_changed_success":
|
||||
_handle_appearance_changed_success(data)
|
||||
"world_left":
|
||||
_world_ready_map = ""
|
||||
_world_ready_confirmed = false
|
||||
_stop_appearance_broadcast_retry()
|
||||
"player_left":
|
||||
_handle_player_left(data)
|
||||
"position_updated":
|
||||
@@ -1031,13 +1220,17 @@ func _warn_empty_message_type_limited(data: Dictionary) -> void:
|
||||
|
||||
# 处理登录成功
|
||||
func _handle_login_success(data: Dictionary) -> void:
|
||||
_guest_mode = false
|
||||
_is_logged_in = true
|
||||
_refreshing_auth_session = false
|
||||
_chat_login_refresh_attempted = false
|
||||
_friend_protocol_supported = true
|
||||
_current_username = data.get("username", "")
|
||||
_current_user_id = str(data.get("userId", data.get("user_id", "")))
|
||||
_current_map = data.get("currentMap", "")
|
||||
_world_ready_map = ""
|
||||
_world_ready_confirmed = false
|
||||
_stop_appearance_broadcast_retry()
|
||||
|
||||
# 重置当前会话缓存(每次登录/重连都清空,重新开始接收消息)
|
||||
reset_session()
|
||||
@@ -1051,6 +1244,18 @@ func _handle_login_success(data: Dictionary) -> void:
|
||||
_flush_queued_messages()
|
||||
request_friend_list()
|
||||
|
||||
func _handle_guest_login_success(data: Dictionary) -> void:
|
||||
_guest_mode = true
|
||||
_is_logged_in = true
|
||||
_friend_protocol_supported = false
|
||||
_current_username = "游客"
|
||||
_current_user_id = ""
|
||||
_current_map = str(data.get("currentMap", "whale_port"))
|
||||
_world_ready_map = ""
|
||||
_world_ready_confirmed = false
|
||||
reset_session()
|
||||
_emit_event(EventNames.CHAT_LOGIN_SUCCESS, {"username": "游客", "current_map": _current_map, "guest": true})
|
||||
|
||||
func _handle_system_presence(data: Dictionary) -> void:
|
||||
var content := str(data.get("content", "")).strip_edges()
|
||||
if content.is_empty():
|
||||
@@ -1070,8 +1275,8 @@ func _handle_system_presence(data: Dictionary) -> void:
|
||||
chat_message_received.emit("系统", content, false, timestamp)
|
||||
_emit_event(EventNames.CHAT_MESSAGE_RECEIVED, message)
|
||||
|
||||
func _on_account_profile_updated(_profile: Dictionary) -> void:
|
||||
notify_appearance_changed()
|
||||
func _on_account_profile_updated(profile: Dictionary) -> void:
|
||||
notify_appearance_changed(str(profile.get("skin_id", "")))
|
||||
|
||||
func _on_auth_state_changed(isAuthenticated: bool, _user: Dictionary) -> void:
|
||||
if not isAuthenticated:
|
||||
@@ -1111,6 +1316,19 @@ func _handle_login_error(data: Dictionary) -> void:
|
||||
func _handle_chat_sent(data: Dictionary) -> void:
|
||||
var message_id: String = str(data.get("messageId", ""))
|
||||
var timestamp: float = data.get("timestamp", 0.0)
|
||||
var is_world_bulletin: bool = bool(data.get("worldBulletin", data.get("world_bulletin", false)))
|
||||
if is_world_bulletin and data.has("balance"):
|
||||
# 直接采用这次后端交易返回的余额,不在客户端自行计算扣费。
|
||||
var player_state_manager := get_node_or_null("/root/PlayerStateManager")
|
||||
if player_state_manager != null and player_state_manager.has_method("apply_wallet"):
|
||||
var wallet: Dictionary = {}
|
||||
if player_state_manager.has_method("get_wallet"):
|
||||
var current_wallet: Variant = player_state_manager.call("get_wallet")
|
||||
if current_wallet is Dictionary:
|
||||
wallet = (current_wallet as Dictionary).duplicate(true)
|
||||
wallet["balance"] = int(data.get("balance", 0))
|
||||
wallet["currency"] = "whale_coin"
|
||||
player_state_manager.call("apply_wallet", wallet)
|
||||
|
||||
# 发射信号
|
||||
chat_message_sent.emit(message_id, timestamp)
|
||||
@@ -1118,24 +1336,28 @@ func _handle_chat_sent(data: Dictionary) -> void:
|
||||
# 通过 EventSystem 广播(Signal Up)
|
||||
_emit_event(EventNames.CHAT_MESSAGE_SENT, {
|
||||
"message_id": message_id,
|
||||
"timestamp": timestamp
|
||||
"timestamp": timestamp,
|
||||
"world_bulletin": is_world_bulletin,
|
||||
"charged": int(data.get("charged", 0)),
|
||||
"balance": int(data.get("balance", 0))
|
||||
})
|
||||
if not _recoverable_messages.is_empty():
|
||||
if not is_world_bulletin and not _recoverable_messages.is_empty():
|
||||
_recoverable_messages.pop_front()
|
||||
|
||||
# 处理聊天消息发送失败
|
||||
func _handle_chat_error(data: Dictionary) -> void:
|
||||
var error_message: String = data.get("message", "消息发送失败")
|
||||
var error_code: String = str(data.get("code", data.get("error_code", "CHAT_SEND_FAILED"))).strip_edges()
|
||||
if error_code == "SESSION_EXPIRED":
|
||||
_handle_session_expired(error_message)
|
||||
return
|
||||
var is_world_bulletin: bool = bool(data.get("worldBulletin", data.get("world_bulletin", false)))
|
||||
|
||||
# 通过 EventSystem 广播错误(Signal Up)
|
||||
_emit_event(EventNames.CHAT_ERROR_OCCURRED, {
|
||||
"error_code": error_code if not error_code.is_empty() else "CHAT_SEND_FAILED",
|
||||
"message": error_message
|
||||
"message": error_message,
|
||||
"world_bulletin": is_world_bulletin
|
||||
})
|
||||
if error_code == "SESSION_EXPIRED":
|
||||
_handle_session_expired(error_message)
|
||||
|
||||
func _handle_friend_list(data: Dictionary) -> void:
|
||||
_friends.clear()
|
||||
@@ -1256,6 +1478,7 @@ func _handle_chat_render(data: Dictionary) -> void:
|
||||
var to_username: String = str(data.get("toUsername", data.get("to_username", data.get("targetUsername", data.get("target_username", ""))))).strip_edges()
|
||||
var private_context: String = str(data.get("privateContext", data.get("private_context", ""))).strip_edges()
|
||||
var is_private: bool = scope == "private"
|
||||
var is_world_bulletin: bool = bool(data.get("worldBulletin", data.get("world_bulletin", false)))
|
||||
|
||||
var timestamp: float = _parse_chat_timestamp_to_unix(data.get("timestamp", 0.0))
|
||||
|
||||
@@ -1280,7 +1503,8 @@ func _handle_chat_render(data: Dictionary) -> void:
|
||||
"to_user_id": to_user_id,
|
||||
"to_username": to_username,
|
||||
"private_context": private_context,
|
||||
"is_private": is_private
|
||||
"is_private": is_private,
|
||||
"world_bulletin": is_world_bulletin
|
||||
})
|
||||
|
||||
# 发射信号
|
||||
@@ -1298,7 +1522,8 @@ func _handle_chat_render(data: Dictionary) -> void:
|
||||
"to_user_id": to_user_id,
|
||||
"to_username": to_username,
|
||||
"private_context": private_context,
|
||||
"is_private": is_private
|
||||
"is_private": is_private,
|
||||
"world_bulletin": is_world_bulletin
|
||||
})
|
||||
|
||||
# 解析聊天消息时间戳(兼容 unix 秒 / ISO 8601 字符串)
|
||||
@@ -1359,13 +1584,16 @@ func _handle_position_update(data: Dictionary) -> void:
|
||||
"userId": user_id,
|
||||
"username": str(data.get("username", "")),
|
||||
"position": Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))),
|
||||
"mapId": str(data.get("mapId", data.get("map_id", ""))),
|
||||
"skin_id": str(appearance.get("skin_id", "")),
|
||||
"avatar_id": str(appearance.get("avatar_id", "")),
|
||||
"skin_asset": appearance.get("skin_asset", {}),
|
||||
"cafe_companion": _normalize_cafe_companion(data.get("cafeCompanion", data.get("cafe_companion", null))),
|
||||
"movement_locked": bool(data.get("movementLocked", data.get("movement_locked", false)))
|
||||
})
|
||||
"direction": str(data.get("direction", "down")),
|
||||
"movement_state": str(data.get("movementState", data.get("movement_state", "walk"))),
|
||||
"sequence": int(data.get("sequence", -1)),
|
||||
"mapId": str(data.get("mapId", data.get("map_id", ""))),
|
||||
"skin_id": str(appearance.get("skin_id", "")),
|
||||
"avatar_id": str(appearance.get("avatar_id", "")),
|
||||
"skin_asset": appearance.get("skin_asset", {}),
|
||||
"cafe_companion": _normalize_cafe_companion(data.get("cafeCompanion", data.get("cafe_companion", null))),
|
||||
"movement_locked": bool(data.get("movementLocked", data.get("movement_locked", false)))
|
||||
})
|
||||
|
||||
func _handle_map_players_snapshot(data: Dictionary) -> void:
|
||||
var players: Array[Dictionary] = []
|
||||
@@ -1383,19 +1611,88 @@ func _handle_map_players_snapshot(data: Dictionary) -> void:
|
||||
"userId": user_id,
|
||||
"username": str(player.get("username", "")),
|
||||
"position": Vector2(float(player.get("x", 0.0)), float(player.get("y", 0.0))),
|
||||
"mapId": str(player.get("mapId", player.get("map_id", data.get("mapId", data.get("map_id", ""))))),
|
||||
"skin_id": str(appearance.get("skin_id", "")),
|
||||
"avatar_id": str(appearance.get("avatar_id", "")),
|
||||
"skin_asset": appearance.get("skin_asset", {}),
|
||||
"cafe_companion": _normalize_cafe_companion(player.get("cafeCompanion", player.get("cafe_companion", null))),
|
||||
"movement_locked": bool(player.get("movementLocked", player.get("movement_locked", false)))
|
||||
})
|
||||
"direction": str(player.get("direction", "down")),
|
||||
"movement_state": str(player.get("movementState", player.get("movement_state", "idle"))),
|
||||
"sequence": int(player.get("sequence", -1)),
|
||||
"mapId": str(player.get("mapId", player.get("map_id", data.get("mapId", data.get("map_id", ""))))),
|
||||
"skin_id": str(appearance.get("skin_id", "")),
|
||||
"avatar_id": str(appearance.get("avatar_id", "")),
|
||||
"skin_asset": appearance.get("skin_asset", {}),
|
||||
"cafe_companion": _normalize_cafe_companion(player.get("cafeCompanion", player.get("cafe_companion", null))),
|
||||
"movement_locked": bool(player.get("movementLocked", player.get("movement_locked", false)))
|
||||
})
|
||||
|
||||
_emit_event(EventNames.REMOTE_PLAYERS_SNAPSHOT_READY, {
|
||||
"mapId": str(data.get("mapId", data.get("map_id", ""))),
|
||||
"players": players
|
||||
})
|
||||
|
||||
func _handle_npc_snapshot(data: Dictionary) -> void:
|
||||
var npcs: Array[Dictionary] = []
|
||||
var npcsVariant: Variant = data.get("npcs", [])
|
||||
if npcsVariant is Array:
|
||||
for npcVariant in npcsVariant:
|
||||
if not (npcVariant is Dictionary):
|
||||
continue
|
||||
var npc: Dictionary = npcVariant
|
||||
var npcId := str(npc.get("npcId", npc.get("npc_id", ""))).strip_edges()
|
||||
if npcId.is_empty():
|
||||
continue
|
||||
npcs.append({
|
||||
"npc_id": npcId,
|
||||
"name": str(npc.get("name", "NPC")),
|
||||
"x": float(npc.get("x", 0.0)),
|
||||
"y": float(npc.get("y", 0.0)),
|
||||
"direction": str(npc.get("direction", "down")),
|
||||
"movement_state": str(npc.get("movementState", npc.get("movement_state", "idle"))),
|
||||
"state": str(npc.get("state", "idle")),
|
||||
"version": int(npc.get("version", 0)),
|
||||
"daily_goal": str(npc.get("dailyGoal", npc.get("daily_goal", ""))),
|
||||
"plan_source": str(npc.get("planSource", npc.get("plan_source", "fallback"))),
|
||||
"current_activity": npc.get("currentActivity", npc.get("current_activity", {})),
|
||||
"public_intention": str(npc.get("publicIntention", npc.get("public_intention", ""))),
|
||||
"dialogue": str(npc.get("dialogue", "")),
|
||||
"scene": str(npc.get("scene", "classic_whale")),
|
||||
"active_action": _normalize_npc_action(npc.get("activeAction", npc.get("active_action", null))),
|
||||
})
|
||||
|
||||
_emit_event(EventNames.NPC_SNAPSHOT_READY, {
|
||||
"mapId": str(data.get("mapId", data.get("map_id", ""))),
|
||||
"server_now": int(data.get("serverNow", data.get("server_now", 0))),
|
||||
"version": int(data.get("version", 0)),
|
||||
"npcs": npcs,
|
||||
})
|
||||
|
||||
func _normalize_npc_action_event(data: Dictionary) -> Dictionary:
|
||||
return {
|
||||
"mapId": str(data.get("mapId", data.get("map_id", ""))),
|
||||
"server_now": int(data.get("serverNow", data.get("server_now", 0))),
|
||||
"npc_id": str(data.get("npcId", data.get("npc_id", ""))),
|
||||
"action": _normalize_npc_action(data.get("action", {})),
|
||||
}
|
||||
|
||||
func _normalize_npc_action(value: Variant) -> Dictionary:
|
||||
if not (value is Dictionary):
|
||||
return {}
|
||||
var action: Dictionary = value
|
||||
return {
|
||||
"action_id": str(action.get("actionId", action.get("action_id", ""))),
|
||||
"kind": str(action.get("kind", "walk")),
|
||||
"from_x": float(action.get("fromX", action.get("from_x", 0.0))),
|
||||
"from_y": float(action.get("fromY", action.get("from_y", 0.0))),
|
||||
"to_x": float(action.get("toX", action.get("to_x", 0.0))),
|
||||
"to_y": float(action.get("toY", action.get("to_y", 0.0))),
|
||||
"from_map_id": str(action.get("fromMapId", action.get("from_map_id", ""))),
|
||||
"to_map_id": str(action.get("toMapId", action.get("to_map_id", ""))),
|
||||
"from_location_id": str(action.get("fromLocationId", action.get("from_location_id", ""))),
|
||||
"to_location_id": str(action.get("toLocationId", action.get("to_location_id", ""))),
|
||||
"activity_id": str(action.get("activityId", action.get("activity_id", ""))),
|
||||
"activity_kind": str(action.get("activityKind", action.get("activity_kind", ""))),
|
||||
"started_at": int(action.get("startedAt", action.get("started_at", 0))),
|
||||
"completes_at": int(action.get("completesAt", action.get("completes_at", 0))),
|
||||
"version": int(action.get("version", 0)),
|
||||
}
|
||||
|
||||
func _handle_player_joined(data: Dictionary) -> void:
|
||||
var user_id := str(data.get("userId", data.get("user_id", ""))).strip_edges()
|
||||
if user_id.is_empty():
|
||||
@@ -1406,19 +1703,25 @@ func _handle_player_joined(data: Dictionary) -> void:
|
||||
"userId": user_id,
|
||||
"username": str(data.get("username", "")),
|
||||
"position": Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))),
|
||||
"mapId": str(data.get("mapId", data.get("map_id", ""))),
|
||||
"skin_id": str(appearance.get("skin_id", "")),
|
||||
"avatar_id": str(appearance.get("avatar_id", "")),
|
||||
"skin_asset": appearance.get("skin_asset", {}),
|
||||
"cafe_companion": _normalize_cafe_companion(data.get("cafeCompanion", data.get("cafe_companion", null))),
|
||||
"movement_locked": bool(data.get("movementLocked", data.get("movement_locked", false)))
|
||||
})
|
||||
"direction": str(data.get("direction", "down")),
|
||||
"movement_state": str(data.get("movementState", data.get("movement_state", "idle"))),
|
||||
"sequence": int(data.get("sequence", -1)),
|
||||
"mapId": str(data.get("mapId", data.get("map_id", ""))),
|
||||
"skin_id": str(appearance.get("skin_id", "")),
|
||||
"avatar_id": str(appearance.get("avatar_id", "")),
|
||||
"skin_asset": appearance.get("skin_asset", {}),
|
||||
"cafe_companion": _normalize_cafe_companion(data.get("cafeCompanion", data.get("cafe_companion", null))),
|
||||
"movement_locked": bool(data.get("movementLocked", data.get("movement_locked", false)))
|
||||
})
|
||||
|
||||
func _normalize_player_appearance(data: Dictionary) -> Dictionary:
|
||||
var appearanceVariant: Variant = data.get("appearance", {})
|
||||
var appearance: Dictionary = appearanceVariant if appearanceVariant is Dictionary else {}
|
||||
var skinId := str(data.get("skinId", data.get("skin_id", appearance.get("skinId", appearance.get("skin_id", ""))))).strip_edges()
|
||||
if skinId.is_empty() or skinId == "pending_initial_skin":
|
||||
skinId = "classic_whale"
|
||||
return {
|
||||
"skin_id": str(data.get("skinId", data.get("skin_id", appearance.get("skinId", appearance.get("skin_id", ""))))),
|
||||
"skin_id": skinId,
|
||||
"avatar_id": str(data.get("avatarId", data.get("avatar_id", appearance.get("avatarId", appearance.get("avatar_id", ""))))),
|
||||
"skin_asset": data.get("skinAsset", data.get("skin_asset", appearance.get("skinAsset", appearance.get("skin_asset", {})))),
|
||||
}
|
||||
|
||||
201
_Core/managers/InputFocusManager.gd
Normal file
201
_Core/managers/InputFocusManager.gd
Normal file
@@ -0,0 +1,201 @@
|
||||
extends Node
|
||||
|
||||
const FOCUS_BORDER_COLOR := Color(0.12, 0.49, 0.82, 1.0)
|
||||
const CARET_COLOR := Color(0.04, 0.31, 0.60, 1.0)
|
||||
const SELECTION_COLOR := Color(0.25, 0.61, 0.91, 0.30)
|
||||
const STYLED_META := &"whaletown_input_focus_styled"
|
||||
const INITIAL_PLACEHOLDER_META := &"whaletown_initial_placeholder"
|
||||
const FOCUS_PLACEHOLDER_META := &"whaletown_focus_placeholder"
|
||||
const NATIVE_INPUT_ID := "whaletown-native-input"
|
||||
|
||||
var _javascript_callback: Variant
|
||||
var _native_inputs: Dictionary = {}
|
||||
var _active_native_input: Control
|
||||
var _bridge_initialized := false
|
||||
|
||||
func _ready() -> void:
|
||||
get_tree().node_added.connect(_on_node_added)
|
||||
call_deferred("_initialize_javascript_bridge")
|
||||
call_deferred("_decorate_existing_inputs")
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if not _bridge_initialized:
|
||||
_initialize_javascript_bridge()
|
||||
|
||||
func _initialize_javascript_bridge() -> void:
|
||||
if _bridge_initialized or OS.get_name() != "Web":
|
||||
return
|
||||
var window: Variant = JavaScriptBridge.get_interface("window")
|
||||
if window == null:
|
||||
return
|
||||
_javascript_callback = JavaScriptBridge.create_callback(_on_native_input_event)
|
||||
window["__whaletownNativeInputCallback"] = _javascript_callback
|
||||
_bridge_initialized = true
|
||||
|
||||
func _exit_tree() -> void:
|
||||
_close_native_input()
|
||||
if _bridge_initialized:
|
||||
var window: Variant = JavaScriptBridge.get_interface("window")
|
||||
if window != null:
|
||||
window["__whaletownNativeInputCallback"] = null
|
||||
|
||||
func _on_node_added(node: Node) -> void:
|
||||
if node is LineEdit or node is TextEdit:
|
||||
_decorate_input.call_deferred(node)
|
||||
|
||||
func _decorate_existing_inputs() -> void:
|
||||
_decorate_branch(get_tree().root)
|
||||
|
||||
func _decorate_branch(node: Node) -> void:
|
||||
if node is LineEdit or node is TextEdit:
|
||||
_decorate_input(node)
|
||||
for child in node.get_children():
|
||||
_decorate_branch(child)
|
||||
|
||||
func _decorate_input(input: Control) -> void:
|
||||
if not is_instance_valid(input) or input.has_meta(STYLED_META):
|
||||
return
|
||||
input.set_meta(STYLED_META, true)
|
||||
input.add_theme_stylebox_override("focus", _create_focus_style(input))
|
||||
input.add_theme_color_override("caret_color", CARET_COLOR)
|
||||
input.add_theme_color_override("selection_color", SELECTION_COLOR)
|
||||
input.add_theme_constant_override("caret_width", 2)
|
||||
input.set_meta(INITIAL_PLACEHOLDER_META, input.placeholder_text)
|
||||
input.focus_entered.connect(_on_input_focus_entered.bind(input))
|
||||
input.focus_exited.connect(_on_input_focus_exited.bind(input))
|
||||
if input is LineEdit:
|
||||
(input as LineEdit).text_changed.connect(func(_value: String) -> void: _sync_native_input(input))
|
||||
else:
|
||||
(input as TextEdit).text_changed.connect(func() -> void: _sync_native_input(input))
|
||||
|
||||
func _on_input_focus_entered(input: Control) -> void:
|
||||
if not is_instance_valid(input):
|
||||
return
|
||||
if input.text.strip_edges().is_empty():
|
||||
input.set_meta(FOCUS_PLACEHOLDER_META, input.placeholder_text)
|
||||
input.placeholder_text = ""
|
||||
_open_native_input(input)
|
||||
|
||||
func _on_input_focus_exited(input: Control) -> void:
|
||||
if not is_instance_valid(input) or not input.text.strip_edges().is_empty():
|
||||
return
|
||||
var placeholder := str(input.get_meta(FOCUS_PLACEHOLDER_META, input.get_meta(INITIAL_PLACEHOLDER_META, "")))
|
||||
if not placeholder.is_empty():
|
||||
input.placeholder_text = placeholder
|
||||
|
||||
func _open_native_input(input: Control) -> void:
|
||||
if not _bridge_initialized or not is_instance_valid(input):
|
||||
return
|
||||
_close_native_input()
|
||||
_active_native_input = input
|
||||
_native_inputs[input.get_instance_id()] = input
|
||||
var callback_name := "window.__whaletownNativeInputCallback"
|
||||
var input_id := str(input.get_instance_id())
|
||||
var initial_text: String = input.text
|
||||
var multiline := input is TextEdit
|
||||
var secret := input is LineEdit and (input as LineEdit).secret
|
||||
var rect := input.get_global_rect()
|
||||
var viewport_size := get_viewport().get_visible_rect().size
|
||||
var script := """
|
||||
(() => {
|
||||
const callback = %s;
|
||||
if (typeof callback !== 'function') return false;
|
||||
const previous = document.getElementById('%s');
|
||||
if (previous) previous.remove();
|
||||
const target = document.createElement(%s ? 'textarea' : 'input');
|
||||
target.id = '%s';
|
||||
target.value = %s;
|
||||
target.spellcheck = false;
|
||||
target.autocomplete = 'off';
|
||||
if (%s) target.type = 'password';
|
||||
const rect = {x: %f, y: %f, width: %f, height: %f};
|
||||
const viewport = {width: %f, height: %f};
|
||||
const canvas = document.getElementById('canvas');
|
||||
const position = () => {
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
const sx = bounds.width / viewport.width;
|
||||
const sy = bounds.height / viewport.height;
|
||||
target.style.left = (bounds.left + rect.x * sx) + 'px';
|
||||
target.style.top = (bounds.top + rect.y * sy) + 'px';
|
||||
target.style.width = (rect.width * sx) + 'px';
|
||||
target.style.height = (rect.height * sy) + 'px';
|
||||
};
|
||||
Object.assign(target.style, {
|
||||
position: 'fixed', zIndex: '10000',
|
||||
opacity: '0.01', color: 'transparent', caretColor: 'transparent',
|
||||
background: 'transparent', border: '0', outline: '0', padding: '0',
|
||||
margin: '0', resize: 'none', WebkitTextFillColor: 'transparent'
|
||||
});
|
||||
const send = (kind) => callback(kind, %s, target.value,
|
||||
target.selectionStart || 0, target.selectionEnd || 0);
|
||||
target.addEventListener('input', () => send('input'));
|
||||
target.addEventListener('blur', () => send('blur'));
|
||||
target.addEventListener('keydown', (event) => {
|
||||
if (!%s && event.key === 'Enter') { event.preventDefault(); send('submit'); }
|
||||
if (event.key === 'Escape') { event.preventDefault(); send('cancel'); }
|
||||
});
|
||||
document.querySelectorAll('.ime').forEach((element) => element.remove());
|
||||
document.body.appendChild(target);
|
||||
position();
|
||||
window.addEventListener('resize', position, {passive: true});
|
||||
target.__whaletownPosition = position;
|
||||
target.focus({preventScroll: true});
|
||||
target.setSelectionRange(target.value.length, target.value.length);
|
||||
return true;
|
||||
})()
|
||||
""" % [callback_name, NATIVE_INPUT_ID, str(multiline).to_lower(), NATIVE_INPUT_ID,
|
||||
JSON.stringify(initial_text), str(secret).to_lower(), rect.position.x, rect.position.y,
|
||||
rect.size.x, rect.size.y, viewport_size.x, viewport_size.y, JSON.stringify(input_id), str(multiline).to_lower()]
|
||||
var opened: Variant = JavaScriptBridge.eval(script, true)
|
||||
if opened != true:
|
||||
_native_inputs.erase(input.get_instance_id())
|
||||
_active_native_input = null
|
||||
|
||||
func _close_native_input() -> void:
|
||||
if _bridge_initialized:
|
||||
JavaScriptBridge.eval("(() => { const el = document.getElementById('%s'); if (el) { if (el.__whaletownPosition) window.removeEventListener('resize', el.__whaletownPosition); el.remove(); } })()" % NATIVE_INPUT_ID, true)
|
||||
_native_inputs.clear()
|
||||
_active_native_input = null
|
||||
|
||||
func _sync_native_input(input: Control) -> void:
|
||||
if not _bridge_initialized or input != _active_native_input or not is_instance_valid(input):
|
||||
return
|
||||
var value: String = input.text
|
||||
var script := "(() => { const el = document.getElementById('%s'); if (el && el.value !== %s) el.value = %s; })()" % [NATIVE_INPUT_ID, JSON.stringify(value), JSON.stringify(value)]
|
||||
JavaScriptBridge.eval(script, true)
|
||||
|
||||
func _on_native_input_event(arguments: Array) -> void:
|
||||
if arguments.size() < 3:
|
||||
return
|
||||
var event_kind := str(arguments[0])
|
||||
var input_id := int(arguments[1])
|
||||
var input := _native_inputs.get(input_id) as Control
|
||||
if not is_instance_valid(input):
|
||||
return
|
||||
var value := str(arguments[2])
|
||||
if input.text != value:
|
||||
input.text = value
|
||||
if input is LineEdit:
|
||||
(input as LineEdit).set_caret_column(value.length())
|
||||
match event_kind:
|
||||
"submit":
|
||||
if input is LineEdit:
|
||||
(input as LineEdit).text_submitted.emit(input.text)
|
||||
"cancel":
|
||||
_close_native_input()
|
||||
|
||||
func _create_focus_style(input: Control) -> StyleBoxFlat:
|
||||
var focusStyle := StyleBoxFlat.new()
|
||||
focusStyle.bg_color = Color.TRANSPARENT
|
||||
focusStyle.border_color = FOCUS_BORDER_COLOR
|
||||
focusStyle.set_border_width_all(2)
|
||||
|
||||
var normalStyle := input.get_theme_stylebox("normal") as StyleBoxFlat
|
||||
if normalStyle != null:
|
||||
focusStyle.corner_radius_top_left = normalStyle.corner_radius_top_left
|
||||
focusStyle.corner_radius_top_right = normalStyle.corner_radius_top_right
|
||||
focusStyle.corner_radius_bottom_right = normalStyle.corner_radius_bottom_right
|
||||
focusStyle.corner_radius_bottom_left = normalStyle.corner_radius_bottom_left
|
||||
else:
|
||||
focusStyle.set_corner_radius_all(6)
|
||||
return focusStyle
|
||||
1
_Core/managers/InputFocusManager.gd.uid
Normal file
1
_Core/managers/InputFocusManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://lg8ud3kgjliv
|
||||
@@ -32,6 +32,7 @@ signal scene_changed(scene_name: String)
|
||||
signal scene_change_started(scene_name: String)
|
||||
signal scene_pack_progress(scene_name: String, downloaded_bytes: int, total_bytes: int)
|
||||
signal scene_pack_failed(scene_name: String, message: String)
|
||||
signal scene_pack_load_finished(pack_key: String, success: bool)
|
||||
|
||||
# ============ 成员变量 ============
|
||||
|
||||
@@ -42,14 +43,18 @@ var _next_scene_position: Variant = null # 下一个场景的初始
|
||||
var _next_spawn_name: String = "" # 下一个场景的出生点名称 (String)
|
||||
var _pack_manifest: Dictionary = {}
|
||||
var _loaded_scene_packs: Dictionary = {}
|
||||
var _loading_scene_packs: Dictionary = {}
|
||||
var _scene_pack_prefetch_queue: Array[String] = []
|
||||
var _scene_pack_prefetch_running: bool = false
|
||||
var _active_pack_request: HTTPRequest
|
||||
var _active_pack_scene_name: String = ""
|
||||
var _active_pack_expected_size: int = 0
|
||||
var _pack_overlay: CanvasLayer
|
||||
var _pack_status_label: Label
|
||||
var _pack_progress_bar: ProgressBar
|
||||
var _web_pack_progress_last_emit_msec: int = 0
|
||||
|
||||
const PACK_MANIFEST_PATH: String = "/packs/manifest.json"
|
||||
const PACK_MANIFEST_PATH: String = "packs/manifest.json"
|
||||
const PACK_CACHE_DIR: String = "user://scene-packs"
|
||||
const SCENE_PACK_KEYS: Dictionary = {
|
||||
"main": "auth",
|
||||
@@ -61,6 +66,14 @@ const SCENE_PACK_KEYS: Dictionary = {
|
||||
"personal_space": "personal_space",
|
||||
}
|
||||
|
||||
# 只预取当前地图可以直接到达的区域。
|
||||
# 顺序很重要:登录进入广场后先准备个人空间,再准备打工区;
|
||||
# 咖啡馆只有在进入打工区后才加入队列。
|
||||
const SCENE_PREFETCH_ROUTES: Dictionary = {
|
||||
"square": ["personal_space", "work_zone"],
|
||||
"work_zone": ["cafe_interior"],
|
||||
}
|
||||
|
||||
# 场景路径映射表
|
||||
# 将场景名称映射到实际的文件路径
|
||||
# 便于统一管理和修改场景路径
|
||||
@@ -142,6 +155,7 @@ func change_scene(scene_name: String, use_transition: bool = true):
|
||||
current_scene_name = scene_name
|
||||
is_changing_scene = false
|
||||
scene_changed.emit(scene_name)
|
||||
_schedule_scene_prefetch(scene_name)
|
||||
|
||||
# 隐藏过渡效果
|
||||
if use_transition:
|
||||
@@ -154,13 +168,15 @@ func _process(_delta: float) -> void:
|
||||
return
|
||||
var downloaded := _active_pack_request.get_downloaded_bytes()
|
||||
var total := _active_pack_request.get_body_size()
|
||||
if total <= 0:
|
||||
total = _active_pack_expected_size
|
||||
if is_instance_valid(_pack_progress_bar):
|
||||
_pack_progress_bar.indeterminate = total <= 0
|
||||
if total > 0:
|
||||
_pack_progress_bar.max_value = total
|
||||
_pack_progress_bar.value = downloaded
|
||||
if is_instance_valid(_pack_status_label):
|
||||
_pack_status_label.text = "正在加载场景 %s" % _format_download_progress(downloaded, total)
|
||||
_pack_status_label.text = _format_scene_loading_status(downloaded, total)
|
||||
_notify_web_auth_pack_progress(downloaded, total)
|
||||
scene_pack_progress.emit(_active_pack_scene_name, downloaded, total)
|
||||
|
||||
@@ -181,44 +197,105 @@ func _ensure_scene_pack(scene_name: String) -> bool:
|
||||
if packKey.is_empty() or bool(_loaded_scene_packs.get(packKey, false)):
|
||||
return true
|
||||
_show_pack_overlay("正在准备场景...")
|
||||
# 场景切换也进入同一个预取队列,避免用户点击入口时与后台下载并发。
|
||||
_queue_scene_pack_prefetch([scene_name])
|
||||
while not bool(_loaded_scene_packs.get(packKey, false)):
|
||||
if not bool(_loading_scene_packs.get(packKey, false)) and not _scene_pack_prefetch_queue.has(scene_name):
|
||||
break
|
||||
await scene_pack_load_finished
|
||||
if bool(_loaded_scene_packs.get(packKey, false)):
|
||||
_hide_pack_overlay()
|
||||
return true
|
||||
_show_pack_error(scene_name, "场景下载失败,请检查网络后重试")
|
||||
return false
|
||||
|
||||
func preload_scene_pack(scene_name: String) -> void:
|
||||
if OS.get_name() != "Web":
|
||||
return
|
||||
_queue_scene_pack_prefetch([scene_name])
|
||||
|
||||
func _schedule_scene_prefetch(scene_name: String) -> void:
|
||||
if OS.get_name() != "Web" or not _should_prefetch_scene_packs():
|
||||
return
|
||||
var routeVariant: Variant = SCENE_PREFETCH_ROUTES.get(scene_name, [])
|
||||
if routeVariant is Array and not (routeVariant as Array).is_empty():
|
||||
_queue_scene_pack_prefetch(routeVariant as Array)
|
||||
|
||||
func _should_prefetch_scene_packs() -> bool:
|
||||
var authManager := get_node_or_null("/root/AuthManager")
|
||||
if authManager == null or not authManager.has_method("is_authenticated"):
|
||||
return false
|
||||
if not bool(authManager.call("is_authenticated")):
|
||||
return false
|
||||
var chatManager := get_node_or_null("/root/ChatManager")
|
||||
return chatManager == null or not chatManager.has_method("is_guest_mode") or not bool(chatManager.call("is_guest_mode"))
|
||||
|
||||
func _queue_scene_pack_prefetch(scene_names: Array) -> void:
|
||||
for sceneNameVariant in scene_names:
|
||||
var sceneName := str(sceneNameVariant).strip_edges()
|
||||
var packKey := str(SCENE_PACK_KEYS.get(sceneName, ""))
|
||||
if packKey.is_empty() or bool(_loaded_scene_packs.get(packKey, false)) or bool(_loading_scene_packs.get(packKey, false)):
|
||||
continue
|
||||
if _scene_pack_prefetch_queue.has(sceneName):
|
||||
continue
|
||||
_scene_pack_prefetch_queue.append(sceneName)
|
||||
_run_scene_pack_prefetch_queue()
|
||||
|
||||
func _run_scene_pack_prefetch_queue() -> void:
|
||||
if _scene_pack_prefetch_running:
|
||||
return
|
||||
_scene_pack_prefetch_running = true
|
||||
while not _scene_pack_prefetch_queue.is_empty():
|
||||
var sceneName: String = str(_scene_pack_prefetch_queue.pop_front())
|
||||
var packKey := str(SCENE_PACK_KEYS.get(sceneName, ""))
|
||||
if packKey.is_empty() or bool(_loaded_scene_packs.get(packKey, false)):
|
||||
continue
|
||||
if not bool(_loading_scene_packs.get(packKey, false)):
|
||||
_start_scene_pack_load(sceneName, packKey)
|
||||
while bool(_loading_scene_packs.get(packKey, false)):
|
||||
await scene_pack_load_finished
|
||||
_scene_pack_prefetch_running = false
|
||||
|
||||
func _start_scene_pack_load(scene_name: String, pack_key: String) -> void:
|
||||
_loading_scene_packs[pack_key] = true
|
||||
_load_scene_pack_task(scene_name, pack_key)
|
||||
|
||||
func _load_scene_pack_task(scene_name: String, pack_key: String) -> void:
|
||||
var success := await _load_scene_pack_file(scene_name, pack_key)
|
||||
if success:
|
||||
_loaded_scene_packs[pack_key] = true
|
||||
_loading_scene_packs.erase(pack_key)
|
||||
scene_pack_load_finished.emit(pack_key, success)
|
||||
|
||||
func _load_scene_pack_file(scene_name: String, pack_key: String) -> bool:
|
||||
if _pack_manifest.is_empty():
|
||||
_pack_manifest = await _download_pack_manifest()
|
||||
if _pack_manifest.is_empty():
|
||||
_show_pack_error(scene_name, "场景清单加载失败,请检查网络后重试")
|
||||
return false
|
||||
var entryVariant: Variant = _pack_manifest.get(packKey, {})
|
||||
var entryVariant: Variant = _pack_manifest.get(pack_key, {})
|
||||
if not (entryVariant is Dictionary):
|
||||
_show_pack_error(scene_name, "服务器缺少场景资源:%s" % packKey)
|
||||
return false
|
||||
var entry := entryVariant as Dictionary
|
||||
var fileName := str(entry.get("file", "")).get_file()
|
||||
if fileName.is_empty() or not fileName.ends_with(".pck"):
|
||||
_show_pack_error(scene_name, "场景资源清单格式错误")
|
||||
return false
|
||||
var expectedSize := int(entry.get("size", 0))
|
||||
var cacheDirAbsolute := ProjectSettings.globalize_path(PACK_CACHE_DIR)
|
||||
DirAccess.make_dir_recursive_absolute(cacheDirAbsolute)
|
||||
var localPath := "%s/%s" % [PACK_CACHE_DIR, fileName]
|
||||
if _is_scene_pack_file_valid(localPath, fileName, expectedSize) and ProjectSettings.load_resource_pack(localPath, true):
|
||||
_loaded_scene_packs[packKey] = true
|
||||
_hide_pack_overlay()
|
||||
return true
|
||||
if FileAccess.file_exists(localPath):
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath))
|
||||
var packUrl := _resolve_web_url("/packs/%s" % fileName)
|
||||
if packUrl.is_empty() or not await _download_pack(scene_name, packUrl, localPath):
|
||||
_show_pack_error(scene_name, "场景下载失败,请检查网络后重试")
|
||||
var packUrl := _resolve_web_url("packs/%s" % fileName)
|
||||
if packUrl.is_empty() or not await _download_pack(scene_name, packUrl, localPath, expectedSize):
|
||||
return false
|
||||
if not _is_scene_pack_file_valid(localPath, fileName, expectedSize):
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath))
|
||||
_show_pack_error(scene_name, "场景资源下载不完整,请重新进入")
|
||||
return false
|
||||
if not ProjectSettings.load_resource_pack(localPath, true):
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath))
|
||||
_show_pack_error(scene_name, "场景资源损坏,请重新进入")
|
||||
return false
|
||||
_loaded_scene_packs[packKey] = true
|
||||
_hide_pack_overlay()
|
||||
return true
|
||||
|
||||
func _is_scene_pack_file_valid(local_path: String, file_name: String, expected_size: int) -> bool:
|
||||
@@ -265,21 +342,24 @@ func _download_pack_manifest() -> Dictionary:
|
||||
var packsVariant: Variant = root.get("packs", {})
|
||||
return packsVariant as Dictionary if packsVariant is Dictionary else {}
|
||||
|
||||
func _download_pack(scene_name: String, url: String, local_path: String) -> bool:
|
||||
func _download_pack(scene_name: String, url: String, local_path: String, expected_size: int) -> bool:
|
||||
var request := HTTPRequest.new()
|
||||
request.timeout = 180.0
|
||||
request.timeout = 900.0
|
||||
request.download_file = local_path
|
||||
add_child(request)
|
||||
_active_pack_request = request
|
||||
_active_pack_scene_name = scene_name
|
||||
_active_pack_expected_size = expected_size
|
||||
if request.request(url) != OK:
|
||||
_active_pack_request = null
|
||||
_active_pack_scene_name = ""
|
||||
_active_pack_expected_size = 0
|
||||
request.queue_free()
|
||||
return false
|
||||
var response: Array = await request.request_completed
|
||||
_active_pack_request = null
|
||||
_active_pack_scene_name = ""
|
||||
_active_pack_expected_size = 0
|
||||
request.queue_free()
|
||||
if response.size() < 2 or int(response[0]) != HTTPRequest.RESULT_SUCCESS:
|
||||
return false
|
||||
@@ -343,6 +423,12 @@ func _format_download_progress(downloaded: int, total: int) -> String:
|
||||
return "%.1f MB" % downloadedMb
|
||||
return "%.1f / %.1f MB" % [downloadedMb, float(total) / 1048576.0]
|
||||
|
||||
func _format_scene_loading_status(downloaded: int, total: int) -> String:
|
||||
if total <= 0:
|
||||
return "正在加载场景..."
|
||||
var percent: int = mini(100, int(round(float(downloaded) / float(total) * 100.0)))
|
||||
return "正在加载场景 %d%%(%s)" % [percent, _format_download_progress(downloaded, total)]
|
||||
|
||||
# ============ 查询方法 ============
|
||||
|
||||
# 获取当前场景名称
|
||||
|
||||
@@ -69,7 +69,7 @@ enum ConnectionState {
|
||||
# ============================================================================
|
||||
|
||||
# WebSocket 服务器 URL(原生 WebSocket)
|
||||
const WEBSOCKET_URL: String = "wss://whaletownend.xinghangee.icu/game"
|
||||
const WEBSOCKET_URL: String = "wss://whaletown.novamailio.com/game"
|
||||
|
||||
# 默认最大重连次数
|
||||
const DEFAULT_MAX_RECONNECT_ATTEMPTS: int = 5
|
||||
@@ -119,6 +119,9 @@ var _heartbeat_timer: Timer
|
||||
|
||||
# 心跳间隔(秒)
|
||||
const HEARTBEAT_INTERVAL: float = 30.0
|
||||
const HEARTBEAT_TIMEOUT: float = 75.0
|
||||
|
||||
var _last_server_message_at_msec: int = 0
|
||||
|
||||
# ============================================================================
|
||||
# 生命周期方法
|
||||
@@ -149,6 +152,7 @@ func _process(_delta: float) -> void:
|
||||
while _websocket_peer.get_available_packet_count() > 0:
|
||||
var packet: PackedByteArray = _websocket_peer.get_packet()
|
||||
var message: String = packet.get_string_from_utf8()
|
||||
_last_server_message_at_msec = Time.get_ticks_msec()
|
||||
|
||||
# 发射消息接收信号
|
||||
data_received.emit(message)
|
||||
@@ -194,9 +198,6 @@ func connect_to_game_server(is_reconnect_attempt: bool = false) -> void:
|
||||
_set_connection_state(ConnectionState.ERROR)
|
||||
return
|
||||
|
||||
# 启动心跳
|
||||
_start_heartbeat()
|
||||
|
||||
# 断开 WebSocket 连接
|
||||
func disconnect_websocket() -> void:
|
||||
_disconnect()
|
||||
@@ -358,9 +359,12 @@ func _on_websocket_connected() -> void:
|
||||
reconnection_succeeded.emit()
|
||||
|
||||
_set_connection_state(ConnectionState.CONNECTED)
|
||||
_last_server_message_at_msec = Time.get_ticks_msec()
|
||||
_start_heartbeat()
|
||||
|
||||
# WebSocket 连接关闭处理
|
||||
func _on_websocket_closed() -> void:
|
||||
_stop_heartbeat()
|
||||
if not _manual_disconnect_requested and _auto_reconnect_enabled:
|
||||
connection_lost.emit()
|
||||
_attempt_reconnect()
|
||||
@@ -382,13 +386,6 @@ func _setup_reconnect_timer() -> void:
|
||||
|
||||
# 尝试重连
|
||||
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)
|
||||
|
||||
@@ -401,7 +398,8 @@ func _attempt_reconnect() -> void:
|
||||
# 计算重连延迟(指数退避)
|
||||
func _calculate_reconnect_delay() -> float:
|
||||
# 指数退避: base_delay * 2^(attempt-1)
|
||||
var delay: float = _reconnect_base_delay * pow(2.0, _reconnect_attempt - 1)
|
||||
var cappedAttempt := mini(_reconnect_attempt, maxi(_max_reconnect_attempts, 1))
|
||||
var delay: float = _reconnect_base_delay * pow(2.0, cappedAttempt - 1)
|
||||
|
||||
# 限制最大延迟
|
||||
return min(delay, MAX_RECONNECT_DELAY)
|
||||
@@ -434,9 +432,16 @@ func _stop_heartbeat() -> void:
|
||||
|
||||
# 心跳超时处理
|
||||
func _on_heartbeat() -> void:
|
||||
# 不发送心跳,避免服务器返回 "消息格式错误"
|
||||
# 如果需要心跳,服务器应该支持特定格式
|
||||
pass
|
||||
if _websocket_peer.get_ready_state() != WebSocketPeer.STATE_OPEN:
|
||||
return
|
||||
var now := Time.get_ticks_msec()
|
||||
if _last_server_message_at_msec > 0 and now - _last_server_message_at_msec > int(HEARTBEAT_TIMEOUT * 1000.0):
|
||||
push_warning("WebSocketManager: 心跳超时,正在重新连接")
|
||||
_websocket_peer.close(4000, "Heartbeat timeout")
|
||||
return
|
||||
var err := _websocket_peer.send_text(JSON.stringify({"type": "ping"}))
|
||||
if err != OK:
|
||||
push_warning("WebSocketManager: 心跳发送失败 - %s" % error_string(err))
|
||||
|
||||
# ============================================================================
|
||||
# 工具方法
|
||||
@@ -455,7 +460,7 @@ func get_state_description() -> String:
|
||||
ConnectionState.CONNECTED:
|
||||
return "已连接"
|
||||
ConnectionState.RECONNECTING:
|
||||
return "重连中 (%d/%d)" % [_reconnect_attempt, _max_reconnect_attempts]
|
||||
return "重连中 (第 %d 次)" % _reconnect_attempt
|
||||
ConnectionState.ERROR:
|
||||
return "错误"
|
||||
_:
|
||||
|
||||
Reference in New Issue
Block a user