feat:增加多角色在线功能

- 增加远程登录角色精灵
- 基于后端接口完成位置同步
- 实现多人在线以及跳转
- 增加个人房间功能
This commit is contained in:
2026-01-10 21:26:15 +08:00
parent ce47bd6eeb
commit ed7d89e39d
21 changed files with 973 additions and 39 deletions

View File

@@ -29,7 +29,7 @@ extends RefCounted
# ============ 信号定义 ============ # ============ 信号定义 ============
# 登录成功信号 # 登录成功信号
signal login_success(username: String) signal login_success(username: String, token: String)
# 登录失败信号 # 登录失败信号
signal login_failed(message: String) signal login_failed(message: String)
@@ -80,6 +80,9 @@ var current_email: String = ""
# 网络请求管理 # 网络请求管理
var active_request_ids: Array = [] var active_request_ids: Array = []
# 当前登录用户ID (静态变量,全局访问)
static var current_user_id: String = ""
# ============ 生命周期方法 ============ # ============ 生命周期方法 ============
# 初始化管理器 # 初始化管理器
@@ -431,12 +434,20 @@ func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary)
if result.success: if result.success:
var username = "" var username = ""
if data.has("data") and data.data.has("user") and data.data.user.has("username"): if data.has("data") and data.data.has("user"):
username = data.data.user.username var user_data = data.data.user
if user_data.has("username"):
username = user_data.username
if user_data.has("id"):
current_user_id = user_data.id
print("AuthManager: Current User ID set to ", current_user_id)
# 延迟发送登录成功信号 # 延迟发送登录成功信号
await Engine.get_main_loop().create_timer(1.0).timeout await Engine.get_main_loop().create_timer(1.0).timeout
login_success.emit(username) var token = ""
if data.has("data") and data.data.has("access_token"):
token = data.data.access_token
login_success.emit(username, token)
else: else:
login_failed.emit(result.message) login_failed.emit(result.message)
@@ -451,11 +462,16 @@ func _on_verification_login_response(success: bool, data: Dictionary, error_info
if result.success: if result.success:
var username = "" var username = ""
if data.has("data") and data.data.has("user") and data.data.user.has("username"): if data.has("data") and data.data.has("user"):
username = data.data.user.username var user_data = data.data.user
if user_data.has("username"):
username = user_data.username
if user_data.has("id"):
current_user_id = user_data.id
print("AuthManager: Current User ID set to ", current_user_id)
await Engine.get_main_loop().create_timer(1.0).timeout await Engine.get_main_loop().create_timer(1.0).timeout
login_success.emit(username) login_success.emit(username, data.get("access_token", ""))
else: else:
login_failed.emit(result.message) login_failed.emit(result.message)

View File

@@ -41,8 +41,13 @@ signal request_failed(request_id: String, error_type: String, message: String)
# ============ 常量定义 ============ # ============ 常量定义 ============
# API基础URL - 所有请求的根地址 # API基础URL - 所有请求的根地址
# [Remote] 正式环境地址 (实际正式项目用此地址)
# [Remote] 正式环境地址 (实际正式项目用此地址)
const API_BASE_URL = "https://whaletownend.xinghangee.icu" const API_BASE_URL = "https://whaletownend.xinghangee.icu"
# [Local] 本地调试地址 (本地调试用此地址)
# const API_BASE_URL = "http://localhost:3000"
# 默认请求超时时间(秒) # 默认请求超时时间(秒)
const DEFAULT_TIMEOUT = 30.0 const DEFAULT_TIMEOUT = 30.0

View File

@@ -37,6 +37,7 @@ signal scene_change_started(scene_name: String)
var current_scene_name: String = "" # 当前场景名称 var current_scene_name: String = "" # 当前场景名称
var is_changing_scene: bool = false # 是否正在切换场景 var is_changing_scene: bool = false # 是否正在切换场景
var _next_scene_position: Variant = null # 下一个场景的初始位置 (Vector2 or null) var _next_scene_position: Variant = null # 下一个场景的初始位置 (Vector2 or null)
var _next_spawn_name: String = "" # 下一个场景的出生点名称 (String)
# 场景路径映射表 # 场景路径映射表
# 将场景名称映射到实际的文件路径 # 将场景名称映射到实际的文件路径
@@ -109,15 +110,18 @@ func change_scene(scene_name: String, use_transition: bool = true):
if use_transition: if use_transition:
await show_transition() await show_transition()
# 更新场景名称(在切换之前设置,确保新场景的 _ready 能获取正确的名称)
current_scene_name = scene_name
# 执行场景切换 # 执行场景切换
var error = get_tree().change_scene_to_file(scene_path) var error = get_tree().change_scene_to_file(scene_path)
if error != OK: if error != OK:
print("场景切换失败: ", error) print("场景切换失败: ", error)
current_scene_name = "" # 恢复为空
is_changing_scene = false is_changing_scene = false
return false return false
# 更新状态 # 更新状态
current_scene_name = scene_name
is_changing_scene = false is_changing_scene = false
scene_changed.emit(scene_name) scene_changed.emit(scene_name)
@@ -147,6 +151,16 @@ func get_next_scene_position() -> Variant:
_next_scene_position = null _next_scene_position = null
return pos return pos
# 设置下一个场景的出生点名称
func set_next_spawn_name(spawn_name: String) -> void:
_next_spawn_name = spawn_name
# 获取并清除下一个场景的出生点名称
func get_next_spawn_name() -> String:
var name = _next_spawn_name
_next_spawn_name = ""
return name
# ============ 场景注册方法 ============ # ============ 场景注册方法 ============

View File

@@ -0,0 +1,172 @@
extends Node
# ============================================================================
# WebSocketManager.gd - WebSocket连接管理器
# ============================================================================
# 负责与后端 Native WebSocket 服务进行实时通信
#
# 协议文档: new_docs/game_architecture_design.md
# 后端地址: ws://localhost:3000/location-broadcast
# ============================================================================
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 WS_URL = "wss://whaletownend.xinghangee.icu/location-broadcast"
const PING_INTERVAL = 25.0 # 秒
var _socket: WebSocketPeer
var _connected: bool = false
var _ping_timer: float = 0.0
var _auth_token: String = ""
func _ready():
_socket = WebSocketPeer.new()
process_mode = Node.PROCESS_MODE_ALWAYS # 保证暂停时也能处理网络
print("WebSocketManager Initialized")
func _process(delta):
_socket.poll()
var state = _socket.get_ready_state()
if state == WebSocketPeer.STATE_OPEN:
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()
func connect_to_server():
if _socket.get_ready_state() == WebSocketPeer.STATE_OPEN:
print("WebSocket 已经是连接状态")
return
print("正在连接 WebSocket: ", WS_URL)
var err = _socket.connect_to_url(WS_URL)
if err != OK:
print("WebSocket 连接请求失败: ", err)
connection_error.emit()
else:
# Godot WebSocket connect is non-blocking, wait for state change in _process
pass
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
}
print("发送加入会话请求: ", map_id, " mapId Payload: ", data.initialPosition.mapId)
send_packet("join_session", data)
func leave_session(map_id: String):
print("发送离开会话请求: ", map_id)
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
}
# print("发送位置更新: ", map_id)
if map_id == "":
print("WARNING: Sending position update with EMPTY mapId! Pos: ", pos)
send_packet("position_update", data)
func _send_heartbeat():
send_packet("heartbeat", {"timestamp": Time.get_unix_time_from_system()})
# ============ 事件处理 ============
func _on_connected():
_connected = true
print("WebSocket 连接成功!")
connected_to_server.emit()
func _on_disconnected():
_connected = false
var code = _socket.get_close_code()
var reason = _socket.get_close_reason()
print("WebSocket 连接断开. Code: %d, Reason: %s" % [code, reason])
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:
print("JSON 解析失败: ", 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", {})
if event != "heartbeat_response":
print("WebSocket Rx: ", event) # Debug logs for all events
match event:
"session_joined":
session_joined.emit(data)
print("加入会话成功,当前房间人数: ", data.get("users", []).size())
"user_joined":
user_joined.emit(data)
print("用户加入: ", data.get("userId"))
"user_left":
user_left.emit(data)
print("用户离开: ", data.get("userId"))
"position_update":
print("WebSocket Rx position_update: ", data.get("userId", "unknown"))
position_updated.emit(data)
"heartbeat_response":
pass # 静默处理
"error":
print("WebSocket 错误事件: ", JSON.stringify(data))
_:
print("未处理的 WebSocket 事件: ", event)

View File

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

View File

@@ -22,6 +22,7 @@ SceneManager="*res://_Core/managers/SceneManager.gd"
EventSystem="*res://_Core/systems/EventSystem.gd" EventSystem="*res://_Core/systems/EventSystem.gd"
NetworkManager="*res://_Core/managers/NetworkManager.gd" NetworkManager="*res://_Core/managers/NetworkManager.gd"
ResponseHandler="*res://_Core/managers/ResponseHandler.gd" ResponseHandler="*res://_Core/managers/ResponseHandler.gd"
WebSocketManager="*res://_Core/managers/WebSocketManager.gd"
[debug] [debug]

View File

@@ -48,10 +48,10 @@ var player_max_energy: int = 100
func _ready(): func _ready():
# 初始化游戏状态 # 初始化游戏状态
# setup_game() setup_game()
# [TEST] 临时绕过登录 # [TEST] 临时绕过登录
call_deferred("_on_login_success", "LocalTester") # call_deferred("_on_login_success", "LocalTester")
# 连接登录成功信号 # 连接登录成功信号
auth_scene.login_success.connect(_on_login_success) auth_scene.login_success.connect(_on_login_success)
@@ -97,14 +97,14 @@ func _setup_test_environment():
var map_instance = map_res.instantiate() var map_instance = map_res.instantiate()
add_child(map_instance) add_child(map_instance)
# 3. 加载玩家 # 3. 加载玩家 - 交由 BaseLevel 或场景脚本动态处理
var player_res = load("res://Scenes/characters/player.tscn") # var player_res = load("res://Scenes/characters/player.tscn")
if player_res: # if player_res:
var player_instance = player_res.instantiate() # var player_instance = player_res.instantiate()
player_instance.position = Vector2(800, 600) # 设置初始位置 # player_instance.position = Vector2(800, 600) # 设置初始位置
map_instance.add_child(player_instance) # map_instance.add_child(player_instance)
else: # else:
print("错误: 无法加载玩家场景") # print("错误: 无法加载玩家场景")
else: else:
print("错误: 无法加载广场地图") print("错误: 无法加载广场地图")
@@ -114,10 +114,15 @@ func update_player_status():
exp_label.text = "经验: " + str(player_exp) + "/" + str(player_max_exp) exp_label.text = "经验: " + str(player_exp) + "/" + str(player_max_exp)
energy_label.text = "体力: " + str(player_energy) + "/" + str(player_max_energy) energy_label.text = "体力: " + str(player_energy) + "/" + str(player_max_energy)
func _on_login_success(username: String): func _on_login_success(username: String, token: String):
# 登录成功后的处理 # 登录成功后的处理
current_user = username current_user = username
print("用户 ", username, " 登录成功!") print("用户 ", username, " 登录成功!")
# 连接到游戏服务器
WebSocketManager.set_auth_token(token)
WebSocketManager.connect_to_server()
show_main_game() show_main_game()
func _on_logout_pressed(): func _on_logout_pressed():

318
scenes/Maps/BaseLevel.gd Normal file
View File

@@ -0,0 +1,318 @@
class_name BaseLevel
extends Node2D
# 基础关卡脚本
# 负责处理通用的关卡逻辑,如玩家生成
@onready var spawn_points = $SpawnPoints if has_node("SpawnPoints") else null
@onready var players_container = $Objects/Players if has_node("Objects/Players") else self
func _ready():
# 延时一帧确保所有子节点就绪
call_deferred("_spawn_player")
# 连接到多人会话
# 获取当前场景名字作为 Session ID
_current_session_id = SceneManager.get_current_scene_name()
if _current_session_id == "":
_current_session_id = "square" # Fallback for direct run
# 如果是私人场景,生成唯一的 Session ID (e.g., room_123)
if _current_session_id in PRIVATE_SCENES:
_current_session_id = _current_session_id + "_" + str(AuthManager.current_user_id)
print("BaseLevel: 进入私密房间实例: ", _current_session_id)
print("BaseLevel: Preparing to join session: ", _current_session_id)
# 如果 WebSocket 已连接,直接加入
if WebSocketManager._socket.get_ready_state() == WebSocketPeer.STATE_OPEN:
_join_session_with_player(_current_session_id)
else:
# 否则等待连接成功信号
WebSocketManager.connected_to_server.connect(func(): _join_session_with_player(_current_session_id))
# 连接远程玩家相关信号
WebSocketManager.session_joined.connect(_on_session_joined)
WebSocketManager.user_joined.connect(_on_user_joined)
WebSocketManager.user_left.connect(_on_user_left)
WebSocketManager.position_updated.connect(_on_position_updated)
# Debug: Simulate fake player to verify rendering
# get_tree().create_timer(2.0).timeout.connect(_debug_fake_activity)
func _exit_tree():
# 场景卸载时不需要再做操作,交给 DoorTeleport 或其他切换逻辑显示处理
pass
func _debug_fake_activity():
print("BaseLevel: Starting fake player simulation")
var fake_id = "fake_ghost"
_on_user_joined({"user": {"userId": fake_id, "username": "Ghost"}, "position": {"x": 500, "y": 500}})
for i in range(20):
await get_tree().create_timer(0.5).timeout
var new_pos = Vector2(500 + i * 20, 500 + i * 10)
_on_position_updated({"userId": fake_id, "position": {"x": new_pos.x, "y": new_pos.y}})
print("BaseLevel: Ghost moved to ", new_pos)
var remote_players: Dictionary = {} # userId -> RemotePlayer instance
var remote_player_scene = preload("res://scenes/characters/remote_player.tscn")
var _player_spawned: bool = false # Track if local player has been spawned
var _local_player: Node = null # Reference to the local player node
var _position_update_timer: float = 0.0 # Throttle timer for position updates
var _current_session_id: String = "" # Cache session ID to avoid race condition on exit
const POSITION_UPDATE_INTERVAL: float = 0.125 # Send at most 8 times per second
const PRIVATE_SCENES = ["room"] # List of scenes that should be private instances
func _on_session_joined(data: Dictionary):
# 对账远程玩家列表,使用 position.mapId 过滤
if not data.has("users"):
return
var current_map = _current_session_id
if current_map == "":
current_map = SceneManager.get_current_scene_name()
if current_map == "":
current_map = "square"
print("BaseLevel: 同步会话 - 当前场景: ", current_map, ", 收到用户数: ", data.users.size())
# 1. 收集服务器返回的、且 mapId 匹配当前场景的用户ID
var valid_user_ids: Array = []
var valid_users: Dictionary = {} # userId -> user data
for user in data.users:
if not user.has("userId") or str(user.userId) == str(AuthManager.current_user_id):
continue
# 检查 position.mapId 是否匹配当前场景
var user_map_id = ""
if user.has("position") and user.position != null:
if typeof(user.position) == TYPE_DICTIONARY and user.position.has("mapId"):
user_map_id = str(user.position.mapId)
print("BaseLevel: 用户 ", user.userId, " 的位置数据: ", user.position)
else:
print("BaseLevel: 用户 ", user.userId, " 没有位置数据")
print("BaseLevel: 用户 ", user.userId, " mapId=", user_map_id, ", 当前场景=", current_map)
if user_map_id == current_map:
var uid = str(user.userId)
valid_user_ids.append(uid)
valid_users[uid] = user
print("BaseLevel: 添加有效用户: ", uid)
else:
# mapId 不匹配,这是"幽灵玩家"
print("BaseLevel: 跳过 mapId 不匹配的玩家: ", user.get("userId"), " (mapId=", user_map_id, ", expected=", current_map, ")")
# 2. 清理幽灵:移除本地有但不在有效列表中的玩家
var local_user_ids = remote_players.keys()
for user_id in local_user_ids:
if str(user_id) not in valid_user_ids:
print("BaseLevel: 清理幽灵玩家: ", user_id)
_remove_remote_player(user_id)
# 3. 添加或更新有效的玩家
for uid in valid_user_ids:
var user = valid_users[uid]
if remote_players.has(uid):
# 已存在,更新位置
_update_remote_player_position(user)
else:
# 不存在,创建新玩家
print("BaseLevel: 创建远程玩家: ", uid)
_add_remote_player(user)
func _on_user_joined(data: Dictionary):
var user = data.get("user", {})
if user.has("userId"):
print("BaseLevel: 新玩家加入: ", user.userId)
if user.userId == AuthManager.current_user_id:
return
# 将 position 数据合并到 user 字典中,以便 _add_remote_player 统一处理
if data.has("position"):
user["position"] = data.position
_add_remote_player(user)
func _on_user_left(data: Dictionary):
var user_id = data.get("userId")
if user_id:
print("BaseLevel: 玩家离开: ", user_id)
_remove_remote_player(user_id)
func _on_position_updated(data: Dictionary):
var user_id = data.get("userId")
if user_id and remote_players.has(user_id):
var player = remote_players[user_id]
# 数据可能直接是位置(扁平)或者包含在 position 字段中
# 根据后端协议: { userId:..., position: {x,y...}, ... }
var pos_data = data.get("position", {})
if pos_data.is_empty():
pos_data = data # 兼容扁平格式
# print("BaseLevel: 收到位置更新: ", user_id, " Data: ", pos_data) # Debug log
# 检查 mapId 是否匹配当前场景
if pos_data.has("mapId") and str(pos_data.mapId) != "":
var current_map = _current_session_id
if current_map == "":
current_map = "square"
if str(pos_data.mapId) != current_map:
print("BaseLevel: 收到异地位置更新,移除幽灵玩家: ", user_id, " (在该玩家在 ", pos_data.mapId, ")")
_remove_remote_player(user_id)
return
if player.has_method("update_position") and pos_data.has("x") and pos_data.has("y"):
player.update_position(Vector2(pos_data.x, pos_data.y))
func _add_remote_player(user_data: Dictionary):
var user_id = str(user_data.get("userId", ""))
if user_id == "":
return
# 防止重复创建
if remote_players.has(user_id):
return
var remote_player = remote_player_scene.instantiate()
# 使用统一的 setup 方法
if remote_player.has_method("setup"):
remote_player.setup(user_data)
else:
# Fallback: 手动设置属性 (如果脚本没更新)
remote_player.position = Vector2.ZERO
if user_data.has("position"):
var p = user_data.position
if p.has("x") and p.has("y"):
remote_player.position = Vector2(p.x, p.y)
# 添加到场景玩家容器
if has_node("Objects/Players"):
$Objects/Players.add_child(remote_player)
else:
add_child(remote_player)
remote_players[user_id] = remote_player
print("BaseLevel: 已创建远程玩家: ", user_id)
func _remove_remote_player(user_id):
var uid = str(user_id)
if remote_players.has(uid):
var player = remote_players[uid]
if is_instance_valid(player):
player.queue_free()
remote_players.erase(uid)
func _update_remote_player_position(user: Dictionary):
var user_id = str(user.get("userId", ""))
var player = remote_players.get(user_id)
if not player or not is_instance_valid(player):
return
if user.has("position"):
var pos = user.position
if pos.has("x") and pos.has("y") and player.has_method("update_position"):
player.update_position(Vector2(pos.x, pos.y))
func _join_session_with_player(session_id: String):
# 检查是否有Token如果没有则等待
if WebSocketManager._auth_token == "":
print("BaseLevel: Token not ready, waiting...")
# 轮询等待Token就绪 (简单重试机制)
await get_tree().create_timer(0.5).timeout
_join_session_with_player(session_id)
return
# 等待玩家生成完毕
if not _player_spawned or not _local_player:
await get_tree().process_frame
_join_session_with_player(session_id)
return
var pos = _local_player.global_position if is_instance_valid(_local_player) else Vector2.ZERO
WebSocketManager.join_session(session_id, pos)
# 强制广播一次位置更新,确保旧房间的玩家立即收到 "已切换地图" 的通知
# 这能解决"需要移动两步幽灵才消失"的问题
await get_tree().create_timer(0.1).timeout
WebSocketManager.send_position_update(session_id, pos)
func _process(delta):
# 发送位置更新 (节流机制)
if not _player_spawned or not _local_player:
return # Wait for player to be spawned
if WebSocketManager._socket.get_ready_state() != WebSocketPeer.STATE_OPEN:
return # WebSocket not connected
if not is_instance_valid(_local_player):
return # Player was freed
# 检查 velocity 属性
if not "velocity" in _local_player:
return
# 只有在移动时才更新计时器和发送
if _local_player.velocity.length() > 0:
_position_update_timer += delta
if _position_update_timer >= POSITION_UPDATE_INTERVAL:
_position_update_timer = 0.0
# 使用 _current_session_id 确保有正确的 fallback
var map_id = _current_session_id if _current_session_id != "" else "square"
WebSocketManager.send_position_update(map_id, _local_player.global_position)
func _spawn_player():
# 1. 确定出生位置
var spawn_pos = Vector2.ZERO
var spawn_name = SceneManager.get_next_spawn_name()
print("BaseLevel: Checking spawn point for name: '", spawn_name, "'")
# 查找逻辑:优先查找名为 spawn_name 的节点,其次找 DefaultSpawn
var target_node_name = spawn_name if spawn_name != "" else "DefaultSpawn"
var marker_node = null
# 策略 A: 在 SpawnPoints 容器中查找
if has_node("SpawnPoints"):
marker_node = $SpawnPoints.get_node_or_null(target_node_name)
# 策略 B: 如果没找到,在当前节点(根节点)下查找
if marker_node == null:
marker_node = get_node_or_null(target_node_name)
# 如果找到了标记点,使用其位置
if marker_node:
spawn_pos = marker_node.global_position
print("BaseLevel: Found spawn marker '", target_node_name, "' at ", spawn_pos)
else:
# 策略 C: 检查 SceneManager 是否有备用坐标 (兼容旧逻辑)
var pos_param = SceneManager.get_next_scene_position()
if pos_param != null:
spawn_pos = pos_param
print("BaseLevel: Using explicit position from SceneManager: ", spawn_pos)
else:
print("BaseLevel: Warning - Could not find marker '", target_node_name, "' and no explicit position set. Using (0,0)")
# 2. 实例化玩家
var player_scene = preload("res://scenes/characters/player.tscn")
var player = player_scene.instantiate()
player.global_position = spawn_pos
# 添加到场景
# 如果有Objects/Players容器则添加进去否则直接添加到当前节点
if has_node("Objects/Players"):
$Objects/Players.add_child(player)
else:
add_child(player)
print("BaseLevel: Player spawned at ", player.global_position)
_local_player = player # Save reference
_player_spawned = true

View File

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

View File

@@ -1,8 +1,9 @@
extends Area2D extends Area2D
# 场景名称 # 场景名称
@export var target_scene_name: String = "room" @export var target_scene_name: String = ""
@export var target_position: Vector2 = Vector2.ZERO # 目标场景的生成位置 (Vector2.ZERO 表示使用默认位置/不设置) @export var target_position: Vector2 = Vector2.ZERO # 兼容旧逻辑
@export var target_spawn_name: String = "" # 新逻辑:指定目标场景的 Marker2D 名称 (例如 "FromSquare") # 目标场景的生成位置 (Vector2.ZERO 表示使用默认位置/不设置)
# Called when the node enters the scene tree for the first time. # Called when the node enters the scene tree for the first time.
func _ready() -> void: func _ready() -> void:
@@ -23,10 +24,16 @@ func _on_body_entered(body: Node2D) -> void:
_teleport_player() _teleport_player()
func _teleport_player() -> void: func _teleport_player() -> void:
# 如果设置了目标位置,则传递给 SceneManager if target_scene_name == "":
if target_position != Vector2.ZERO: print("Error: Target scene name is empty!")
return
print("Teleporting to scene: ", target_scene_name)
# 设置参数
if target_spawn_name != "":
SceneManager.set_next_spawn_name(target_spawn_name)
elif target_position != Vector2.ZERO:
SceneManager.set_next_scene_position(target_position) SceneManager.set_next_scene_position(target_position)
# 使用 SceneManager 切换场景
# 确保 SceneManager 已经注册了相关场景路径
SceneManager.change_scene(target_scene_name) SceneManager.change_scene(target_scene_name)

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -0,0 +1,87 @@
extends CharacterBody2D
# 远程玩家脚本
# 负责处理位置同步和动画播放
# 严格遵循 Visual Only 原则:无输入处理,无物理碰撞
# 公共属性 (snake_case)
var user_id: String = ""
var target_position: Vector2 = Vector2.ZERO
# 内部状态
var last_direction: String = "down"
@onready var animation_player: AnimationPlayer = $AnimationPlayer
@onready var sprite: Sprite2D = $Sprite2D
func _ready():
# 初始化时确保无物理处理
set_physics_process(false)
# 初始位置设为当前位置
target_position = global_position
# 确保禁用物理碰撞 (双重保险)
if has_node("CollisionShape2D"):
$CollisionShape2D.disabled = true
func _process(delta: float):
# 1. 平滑移动插值
var current_pos = global_position
var dist = current_pos.distance_to(target_position)
if dist > 1.0:
# 简单的线性插值,速度系数 10.0 可根据需要调整
var new_pos = current_pos.lerp(target_position, 10.0 * delta)
# 计算移动向量用于动画朝向
var move_vec = new_pos - current_pos
_update_animation(move_vec)
global_position = new_pos
else:
# 距离很近时直接吸附并播放待机动画
global_position = target_position
_play_idle_animation()
# 统一初始化方法
# data: 包含 camelCase 字段的字典 (userId, username, position 等)
func setup(data: Dictionary):
if data.has("userId"):
user_id = data.userId
if data.has("position"):
var pos_data = data.position
if pos_data.has("x") and pos_data.has("y"):
var new_pos = Vector2(pos_data.x, pos_data.y)
global_position = new_pos
target_position = new_pos
# 如果有名字显示需求,可在此扩展
# if data.has("username") and has_node("Label"):
# $Label.text = data.username
# 更新目标位置
func update_position(new_pos: Vector2):
target_position = new_pos
# 动画更新逻辑 (复用 PlayerController 的命名规范)
func _update_animation(move_vec: Vector2):
if not animation_player:
return
# 确定主方向
if abs(move_vec.x) > abs(move_vec.y):
if move_vec.x > 0:
last_direction = "right"
else:
last_direction = "left"
else:
if move_vec.y > 0:
last_direction = "down"
else:
last_direction = "up"
animation_player.play("walk_" + last_direction)
func _play_idle_animation():
if animation_player:
animation_player.play("idle_" + last_direction)

View File

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

View File

@@ -1,6 +1,6 @@
[gd_scene load_steps=13 format=3 uid="uid://b2f8e24plwqgj"] [gd_scene load_steps=13 format=3 uid="uid://b2f8e24plwqgj"]
[ext_resource type="Script" uid="uid://btka26hrcvgen" path="res://Scenes/characters/player.gd" id="1_script"] [ext_resource type="Script" uid="uid://fdswi18nel8n" path="res://scenes/characters/PlayerController.gd" id="1_script"]
[ext_resource type="Texture2D" uid="uid://cghab1hkx5lg5" path="res://assets/characters/player_spritesheet.png" id="2_texture"] [ext_resource type="Texture2D" uid="uid://cghab1hkx5lg5" path="res://assets/characters/player_spritesheet.png" id="2_texture"]
[sub_resource type="CapsuleShape2D" id="CapsuleShape2D_1"] [sub_resource type="CapsuleShape2D" id="CapsuleShape2D_1"]

View File

@@ -0,0 +1,175 @@
[gd_scene load_steps=13 format=3 uid="uid://chb8mcqhfnkkr"]
[ext_resource type="Script" uid="uid://dtbajfsljdht5" path="res://scenes/characters/RemotePlayer.gd" id="1_mu86i"]
[ext_resource type="Texture2D" uid="uid://cghab1hkx5lg5" path="res://assets/characters/player_spritesheet.png" id="2_7oc7u"]
[sub_resource type="CapsuleShape2D" id="CapsuleShape2D_1"]
radius = 21.0
height = 48.0
[sub_resource type="Animation" id="Animation_idle_down"]
resource_name = "idle_down"
length = 0.1
loop_mode = 1
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Sprite2D:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [0]
}
[sub_resource type="Animation" id="Animation_idle_left"]
resource_name = "idle_left"
length = 0.1
loop_mode = 1
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Sprite2D:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [12]
}
[sub_resource type="Animation" id="Animation_idle_right"]
resource_name = "idle_right"
length = 0.1
loop_mode = 1
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Sprite2D:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [8]
}
[sub_resource type="Animation" id="Animation_idle_up"]
resource_name = "idle_up"
length = 0.1
loop_mode = 1
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Sprite2D:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [4]
}
[sub_resource type="Animation" id="Animation_walk_down"]
resource_name = "walk_down"
length = 0.8
loop_mode = 1
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Sprite2D:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6),
"transitions": PackedFloat32Array(1, 1, 1, 1),
"update": 1,
"values": [0, 1, 2, 3]
}
[sub_resource type="Animation" id="Animation_walk_left"]
resource_name = "walk_left"
length = 0.8
loop_mode = 1
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Sprite2D:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6),
"transitions": PackedFloat32Array(1, 1, 1, 1),
"update": 1,
"values": [12, 13, 14, 15]
}
[sub_resource type="Animation" id="Animation_walk_right"]
resource_name = "walk_right"
length = 0.8
loop_mode = 1
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Sprite2D:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6),
"transitions": PackedFloat32Array(1, 1, 1, 1),
"update": 1,
"values": [8, 9, 10, 11]
}
[sub_resource type="Animation" id="Animation_walk_up"]
resource_name = "walk_up"
length = 0.8
loop_mode = 1
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Sprite2D:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6),
"transitions": PackedFloat32Array(1, 1, 1, 1),
"update": 1,
"values": [4, 5, 6, 7]
}
[sub_resource type="AnimationLibrary" id="AnimationLibrary_1"]
_data = {
&"idle_down": SubResource("Animation_idle_down"),
&"idle_left": SubResource("Animation_idle_left"),
&"idle_right": SubResource("Animation_idle_right"),
&"idle_up": SubResource("Animation_idle_up"),
&"walk_down": SubResource("Animation_walk_down"),
&"walk_left": SubResource("Animation_walk_left"),
&"walk_right": SubResource("Animation_walk_right"),
&"walk_up": SubResource("Animation_walk_up")
}
[node name="RemotePlayer" type="CharacterBody2D"]
script = ExtResource("1_mu86i")
[node name="Sprite2D" type="Sprite2D" parent="."]
position = Vector2(1.5000005, -24.5)
texture = ExtResource("2_7oc7u")
hframes = 4
vframes = 4
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
position = Vector2(2, -24)
shape = SubResource("CapsuleShape2D_1")
disabled = true
[node name="AnimationPlayer" type="AnimationPlayer" parent="."]
libraries = {
&"": SubResource("AnimationLibrary_1")
}

View File

@@ -26,7 +26,7 @@ extends Control
# ============ 信号定义 ============ # ============ 信号定义 ============
# 登录成功信号 - 传递给上层场景 # 登录成功信号 - 传递给上层场景
signal login_success(username: String) signal login_success(username: String, token: String)
# ============ UI节点引用 ============ # ============ UI节点引用 ============
@@ -328,7 +328,7 @@ func _on_login_enter(_text: String):
# ============ 控制器信号处理 ============ # ============ 控制器信号处理 ============
# 登录成功处理 # 登录成功处理
func _on_controller_login_success(username: String): func _on_controller_login_success(username: String, token: String):
# 清空表单 # 清空表单
login_username.text = "" login_username.text = ""
login_password.text = "" login_password.text = ""
@@ -338,7 +338,7 @@ func _on_controller_login_success(username: String):
_hide_field_error(login_verification_error) _hide_field_error(login_verification_error)
# 发送登录成功信号给上层 # 发送登录成功信号给上层
login_success.emit(username) login_success.emit(username, token)
# 登录失败处理 # 登录失败处理
func _on_controller_login_failed(_message: String): func _on_controller_login_failed(_message: String):

118
test_client.js Normal file
View File

@@ -0,0 +1,118 @@
const WebSocket = require('ws');
const http = require('http');
// Mock Data
// const TOKEN = "mock_token_for_validation"; // Assuming manual auth is bypassed or working
const WS_URL = 'ws://localhost:3000/location-broadcast';
const SESSION_ID = 'debug_room';
function loginAndStart() {
// 1. Try Register first (in case user doesn't exist)
const registerData = JSON.stringify({
username: 'testuser1',
password: 'password123',
nickname: 'TestUser'
});
const regOptions = {
hostname: 'localhost',
port: 3000,
path: '/auth/register',
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': registerData.length }
};
const regReq = http.request(regOptions, (res) => {
// Regardless of success (maybe already exists), try login
performLogin();
});
regReq.on('error', () => performLogin()); // Proceed to login on error
regReq.write(registerData);
regReq.end();
}
function performLogin() {
const postData = JSON.stringify({
identifier: 'testuser1', // Using a test account
password: 'password123'
});
const options = {
hostname: 'localhost',
port: 3000,
path: '/auth/login',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': postData.length
}
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
if (res.statusCode === 201 || res.statusCode === 200) {
const json = JSON.parse(data);
const realToken = json.data.access_token;
console.log("Got Token:", realToken.substring(0, 10) + "...");
startClients(realToken);
} else {
console.error("Login Failed:", res.statusCode, data);
}
});
});
req.on('error', (e) => {
console.error(`Login Request Error: ${e.message}`);
});
req.write(postData);
req.end();
}
function startClients(validToken) {
console.log("Starting Test Clients...");
createClient('Listener', validToken);
setTimeout(() => createClient('Sender', validToken), 1000);
}
function createClient(name, token) {
const ws = new WebSocket(WS_URL);
ws.on('open', () => {
// console.log(`[${name}] Connected`);
setTimeout(() => {
// console.log(`[${name}] Sending Join Session...`);
const joinMsg = {
event: 'join_session',
data: {
sessionId: SESSION_ID,
token: token
}
};
ws.send(JSON.stringify(joinMsg));
}, 500);
});
ws.on('message', (data) => {
console.log(`[${name}] Rx: ${data.toString()}`);
});
ws.on('close', (code, reason) => {
console.log(`[${name}] Closed: ${code} ${reason}`);
});
ws.on('error', (err) => {
console.error(`[${name}] Error:`, err.message);
});
}
// Start
loginAndStart();
// Timeout
setTimeout(() => {
console.log("❌ Test Timed Out - No broadcast received.");
process.exit(1);
}, 15000);