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:
@@ -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", {})))),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user