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:
2026-09-08 21:37:43 +08:00
parent 175621f66c
commit fc6c3c1bd3
87 changed files with 4546 additions and 506 deletions

View File

@@ -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"

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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", {})))),
}

View 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

View File

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

View File

@@ -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)]
# ============ 查询方法 ============
# 获取当前场景名称

View File

@@ -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 "错误"
_: