221 lines
6.2 KiB
GDScript
221 lines
6.2 KiB
GDScript
extends Node
|
||
|
||
# ============================================================================
|
||
# LocationManager.gd - 位置同步管理器
|
||
# ============================================================================
|
||
# 负责与后端 WebSocket 服务进行位置同步和多人会话管理
|
||
#
|
||
# 协议文档: new_docs/game_architecture_design.md
|
||
# 后端地址默认值: wss://whaletownend.xinghangee.icu/game
|
||
# 可通过以下方式覆盖:
|
||
# 1) 环境变量 WHALETOWN_LOCATION_WS_URL
|
||
# 2) Config/game_config.json 或 config/game_config.json 中的
|
||
# network.location_ws_url / network.game_ws_url
|
||
# ============================================================================
|
||
|
||
signal connected_to_server()
|
||
signal connection_closed()
|
||
signal connection_error()
|
||
signal session_joined(data: Dictionary)
|
||
signal user_joined(data: Dictionary)
|
||
signal user_left(data: Dictionary)
|
||
signal position_updated(data: Dictionary)
|
||
|
||
const DEFAULT_WS_URL: String = "wss://whaletownend.xinghangee.icu/game"
|
||
const WS_URL_ENV_KEY: String = "WHALETOWN_LOCATION_WS_URL"
|
||
const PING_INTERVAL = 25.0 # 秒
|
||
|
||
var _socket: WebSocketPeer
|
||
var _connected: bool = false
|
||
var _ping_timer: float = 0.0
|
||
var _auth_token: String = ""
|
||
var _connection_error_reported: bool = false
|
||
var _is_connecting: bool = false
|
||
var _ws_url: String = DEFAULT_WS_URL
|
||
|
||
func _ready():
|
||
_socket = WebSocketPeer.new()
|
||
process_mode = Node.PROCESS_MODE_ALWAYS # 保证暂停时也能处理网络
|
||
_ws_url = _resolve_ws_url()
|
||
|
||
func _process(delta):
|
||
_socket.poll()
|
||
|
||
var state = _socket.get_ready_state()
|
||
|
||
if state == WebSocketPeer.STATE_OPEN:
|
||
_connection_error_reported = false
|
||
_is_connecting = false
|
||
|
||
if not _connected:
|
||
_on_connected()
|
||
|
||
# 处理接收到的数据包
|
||
while _socket.get_available_packet_count() > 0:
|
||
var packet = _socket.get_packet()
|
||
_handle_packet(packet)
|
||
|
||
# 心跳处理
|
||
_ping_timer += delta
|
||
if _ping_timer >= PING_INTERVAL:
|
||
_send_heartbeat()
|
||
_ping_timer = 0.0
|
||
|
||
elif state == WebSocketPeer.STATE_CLOSED:
|
||
if _connected:
|
||
_on_disconnected()
|
||
elif _is_connecting and not _connection_error_reported:
|
||
var close_code := _socket.get_close_code()
|
||
var close_reason := _socket.get_close_reason()
|
||
push_warning(
|
||
"LocationManager: WebSocket 握手失败,close_code=%d, reason=%s" % [close_code, close_reason]
|
||
)
|
||
connection_error.emit()
|
||
_connection_error_reported = true
|
||
_is_connecting = false
|
||
|
||
func connect_to_server():
|
||
var state: WebSocketPeer.State = _socket.get_ready_state()
|
||
if state == WebSocketPeer.STATE_OPEN or state == WebSocketPeer.STATE_CONNECTING:
|
||
return
|
||
|
||
_connection_error_reported = false
|
||
_is_connecting = true
|
||
var err = _socket.connect_to_url(_ws_url)
|
||
if err != OK:
|
||
push_error("LocationManager: WebSocket 连接请求失败,url=%s, 错误码: %d" % [_ws_url, err])
|
||
connection_error.emit()
|
||
_connection_error_reported = true
|
||
_is_connecting = false
|
||
else:
|
||
# Godot WebSocket connect is non-blocking, wait for state change in _process
|
||
pass
|
||
|
||
func _resolve_ws_url() -> String:
|
||
var env_url: String = OS.get_environment(WS_URL_ENV_KEY).strip_edges()
|
||
if not env_url.is_empty():
|
||
return env_url
|
||
|
||
for config_path in ["res://Config/game_config.json", "res://config/game_config.json"]:
|
||
var config_url: String = _load_ws_url_from_config(config_path)
|
||
if not config_url.is_empty():
|
||
return config_url
|
||
|
||
return DEFAULT_WS_URL
|
||
|
||
func _load_ws_url_from_config(config_path: String) -> String:
|
||
if not FileAccess.file_exists(config_path):
|
||
return ""
|
||
|
||
var content: String = FileAccess.get_file_as_string(config_path)
|
||
if content.is_empty():
|
||
return ""
|
||
|
||
var json := JSON.new()
|
||
if json.parse(content) != OK:
|
||
push_warning("LocationManager: 读取配置失败 %s - %s" % [config_path, json.get_error_message()])
|
||
return ""
|
||
|
||
var data_variant: Variant = json.data
|
||
if not (data_variant is Dictionary):
|
||
return ""
|
||
|
||
var root: Dictionary = data_variant
|
||
var network_variant: Variant = root.get("network", {})
|
||
if not (network_variant is Dictionary):
|
||
return ""
|
||
|
||
var network_config: Dictionary = network_variant
|
||
var ws_url: String = str(network_config.get("location_ws_url", network_config.get("game_ws_url", ""))).strip_edges()
|
||
return ws_url
|
||
|
||
func close_connection():
|
||
_socket.close()
|
||
|
||
func set_auth_token(token: String):
|
||
_auth_token = token
|
||
|
||
# ============ 协议发送 ============
|
||
|
||
func send_packet(event: String, data: Dictionary):
|
||
if _socket.get_ready_state() != WebSocketPeer.STATE_OPEN:
|
||
return
|
||
|
||
var message = {
|
||
"event": event,
|
||
"data": data
|
||
}
|
||
var json_str = JSON.stringify(message)
|
||
_socket.put_packet(json_str.to_utf8_buffer())
|
||
|
||
func join_session(map_id: String, initial_pos: Vector2):
|
||
var data = {
|
||
"sessionId": map_id,
|
||
"initialPosition": {
|
||
"x": initial_pos.x,
|
||
"y": initial_pos.y,
|
||
"mapId": map_id
|
||
},
|
||
"token": _auth_token
|
||
}
|
||
send_packet("join_session", data)
|
||
|
||
func leave_session(map_id: String):
|
||
send_packet("leave_session", {"sessionId": map_id})
|
||
|
||
func send_position_update(map_id: String, pos: Vector2, anim_data: Dictionary = {}):
|
||
var data = {
|
||
"x": pos.x,
|
||
"y": pos.y,
|
||
"mapId": map_id,
|
||
"metadata": anim_data
|
||
}
|
||
if map_id == "":
|
||
push_warning("LocationManager: position_update 的 map_id 为空")
|
||
send_packet("position_update", data)
|
||
|
||
func _send_heartbeat():
|
||
send_packet("heartbeat", {"timestamp": Time.get_unix_time_from_system()})
|
||
|
||
# ============ 事件处理 ============
|
||
|
||
func _on_connected():
|
||
_connected = true
|
||
connected_to_server.emit()
|
||
|
||
func _on_disconnected():
|
||
_connected = false
|
||
connection_closed.emit()
|
||
|
||
func _handle_packet(packet: PackedByteArray):
|
||
var json_str = packet.get_string_from_utf8()
|
||
var json = JSON.new()
|
||
var err = json.parse(json_str)
|
||
|
||
if err != OK:
|
||
push_error("LocationManager: JSON 解析失败 - %s" % json.get_error_message())
|
||
return
|
||
|
||
var message = json.data
|
||
if not message is Dictionary or not message.has("event"):
|
||
return
|
||
|
||
var event = message["event"]
|
||
var data = message.get("data", {})
|
||
|
||
match event:
|
||
"session_joined":
|
||
session_joined.emit(data)
|
||
"user_joined":
|
||
user_joined.emit(data)
|
||
"user_left":
|
||
user_left.emit(data)
|
||
"position_update":
|
||
position_updated.emit(data)
|
||
"heartbeat_response":
|
||
pass # 静默处理
|
||
"error":
|
||
push_error("LocationManager: WebSocket 错误事件 - %s" % JSON.stringify(data))
|
||
_:
|
||
push_warning("LocationManager: 未处理的 WebSocket 事件 %s" % event)
|