Initial WhaleTown V2 frontend

This commit is contained in:
2026-07-19 22:42:20 +08:00
commit 435c578dde
1421 changed files with 54486 additions and 0 deletions

View File

@@ -0,0 +1,539 @@
extends Node
# ============================================================================
# MapMultiplayerController.gd - 地图远端玩家同步
# ============================================================================
# 负责把本地玩家位置发送给聊天 WebSocket并把后端位置广播渲染为远端玩家。
# ============================================================================
const REMOTE_PLAYER_SCENE: PackedScene = preload("res://scenes/characters/remote_player.tscn")
const CHAT_BUBBLE_SCENE: PackedScene = preload("res://scenes/ui/ChatBubble.tscn")
const DEFAULT_MAP_ID: String = "whale_port"
const POSITION_SEND_INTERVAL: float = 0.12
const MIN_POSITION_DELTA: float = 4.0
const CHAT_BUBBLE_LAYER_NAME: String = "WorldChatBubbleLayer"
const CHAT_BUBBLE_TARGET_OFFSET: Vector2 = Vector2(0, -84)
const PRIVATE_CHAT_INTERACTION_DISTANCE: float = 150.0
const PRIVATE_CHAT_FORWARD_DOT_THRESHOLD: float = 0.25
@export var map_id: String = DEFAULT_MAP_ID
@export var local_player_path: NodePath = NodePath("../Characters/Players/Player")
@export var remote_players_root_path: NodePath = NodePath("../Characters/Players/RemotePlayers")
var _local_player: Node2D
var _remote_players_root: Node2D
var _remote_players: Dictionary = {}
var _pending_remote_players: Dictionary = {}
var _last_sent_position: Vector2 = Vector2.INF
var _last_sent_at_msec: int = 0
var _last_private_chat_interaction_msec: int = 0
var _last_friend_request_interaction_msec: int = 0
func _ready() -> void:
_local_player = get_node_or_null(local_player_path) as Node2D
_remote_players_root = get_node_or_null(remote_players_root_path) as Node2D
if _local_player == null:
push_warning("MapMultiplayerController: local player not found.")
if _remote_players_root == null:
push_warning("MapMultiplayerController: remote players root not found.")
var eventSystem := _get_event_system()
if eventSystem == null:
push_warning("MapMultiplayerController: EventSystem autoload is not available.")
return
eventSystem.call("connect_event", EventNames.PLAYER_MOVED, _on_local_player_moved, self)
eventSystem.call("connect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_chat_login_success, self)
eventSystem.call("connect_event", EventNames.REMOTE_PLAYERS_SNAPSHOT_READY, _on_remote_players_snapshot_ready, self)
eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_POSITION_UPDATED, _on_remote_player_position_updated, self)
eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_JOINED, _on_remote_player_joined, self)
eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_LEFT, _on_remote_player_left, self)
eventSystem.call("connect_event", EventNames.REMOTE_SKIN_READY, _on_remote_skin_ready, self)
eventSystem.call("connect_event", EventNames.REMOTE_SKIN_FAILED, _on_remote_skin_failed, self)
eventSystem.call("connect_event", EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
eventSystem.call("connect_event", EventNames.INTERACT_PRESSED, _on_interact_pressed, self)
call_deferred("_send_world_ready")
func _process(_delta: float) -> void:
if Input.is_action_just_pressed("interact"):
_try_start_private_chat()
if Input.is_action_just_pressed("friend_request"):
_try_request_friend()
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("interact"):
if _try_start_private_chat():
get_viewport().set_input_as_handled()
return
if event.is_action_pressed("friend_request") and _try_request_friend():
get_viewport().set_input_as_handled()
func _exit_tree() -> void:
var eventSystem := _get_event_system()
if eventSystem == null:
return
eventSystem.call("disconnect_event", EventNames.PLAYER_MOVED, _on_local_player_moved, self)
eventSystem.call("disconnect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_chat_login_success, self)
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYERS_SNAPSHOT_READY, _on_remote_players_snapshot_ready, self)
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_POSITION_UPDATED, _on_remote_player_position_updated, self)
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_JOINED, _on_remote_player_joined, self)
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_LEFT, _on_remote_player_left, self)
eventSystem.call("disconnect_event", EventNames.REMOTE_SKIN_READY, _on_remote_skin_ready, self)
eventSystem.call("disconnect_event", EventNames.REMOTE_SKIN_FAILED, _on_remote_skin_failed, self)
eventSystem.call("disconnect_event", EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
eventSystem.call("disconnect_event", EventNames.INTERACT_PRESSED, _on_interact_pressed, self)
func _get_event_system() -> Node:
return get_node_or_null("/root/EventSystem")
func _get_chat_manager() -> Node:
return get_node_or_null("/root/ChatManager")
func _get_settings_manager() -> Node:
return get_node_or_null("/root/SettingsManager")
func _find_chat_ui() -> Control:
var current: Node = self
while current != null:
var chat_ui := current.get_node_or_null("UILayer/ChatUI") as Control
if chat_ui != null:
return chat_ui
current = current.get_parent()
return null
func _is_text_input_focused() -> bool:
var focusOwner: Control = get_viewport().gui_get_focus_owner()
if focusOwner == null or not focusOwner.is_inside_tree() or not focusOwner.is_visible_in_tree():
return false
if focusOwner is LineEdit:
var lineEdit := focusOwner as LineEdit
return lineEdit.editable
if focusOwner is TextEdit:
var textEdit := focusOwner as TextEdit
return textEdit.editable
return false
func _on_interact_pressed(_data: Dictionary = {}) -> void:
_try_start_private_chat()
func _try_start_private_chat() -> bool:
if _is_text_input_focused():
return false
if not _settings_bool("allow_nearby_private", true):
return false
var now := Time.get_ticks_msec()
if _last_private_chat_interaction_msec > 0 and now - _last_private_chat_interaction_msec < 160:
return false
var target := _find_private_chat_target()
if target == null:
return false
var eventSystem := _get_event_system()
if eventSystem == null:
return false
_last_private_chat_interaction_msec = now
eventSystem.call("emit_event", EventNames.CHAT_PRIVATE_TARGET_SELECTED, {
"userId": str(target.get("userId")),
"username": str(target.get("username"))
})
return true
func _try_request_friend() -> bool:
if _is_text_input_focused():
return false
if not _settings_bool("allow_nearby_friend_requests", true):
return false
var now := Time.get_ticks_msec()
if _last_friend_request_interaction_msec > 0 and now - _last_friend_request_interaction_msec < 220:
return false
var target := _find_private_chat_target()
if target == null:
return false
var chat_ui := _find_chat_ui()
if chat_ui == null or not chat_ui.has_method("request_friend_with_target"):
return false
_last_friend_request_interaction_msec = now
return bool(chat_ui.call(
"request_friend_with_target",
str(target.get("userId")),
str(target.get("username"))
))
func _on_chat_login_success(_data: Dictionary) -> void:
_send_world_ready()
func _on_local_player_moved(data: Dictionary) -> void:
var position_variant: Variant = data.get("position", null)
if position_variant is Vector2:
_send_position(position_variant as Vector2)
func _send_world_ready() -> void:
if _local_player == null:
return
var chatManager := _get_chat_manager()
if chatManager == null or not chatManager.has_method("mark_world_ready"):
return
chatManager.call("mark_world_ready", _get_map_id(), _local_player.global_position)
func _send_position(position: Vector2, force: bool = false) -> void:
var now := Time.get_ticks_msec()
if not force and now - _last_sent_at_msec < int(POSITION_SEND_INTERVAL * 1000.0):
return
if not force and _last_sent_position != Vector2.INF and _last_sent_position.distance_to(position) < MIN_POSITION_DELTA:
return
var chatManager := _get_chat_manager()
if chatManager == null:
return
if chatManager.has_method("can_attempt_chat_connection") and not bool(chatManager.call("can_attempt_chat_connection")):
return
_last_sent_position = position
_last_sent_at_msec = now
chatManager.call("update_player_position", position.x, position.y, _get_map_id())
func _on_remote_player_joined(data: Dictionary) -> void:
if not _is_event_for_current_map(data):
return
var user_id := str(data.get("userId", "")).strip_edges()
if user_id.is_empty():
return
var remote_player := _ensure_remote_player(user_id, data)
var position_variant: Variant = data.get("position", null)
if remote_player != null and position_variant is Vector2 and remote_player.has_method("update_position"):
remote_player.call("update_position", position_variant as Vector2)
func _on_remote_players_snapshot_ready(data: Dictionary) -> void:
if not _is_event_for_current_map(data):
return
var seen_user_ids: Dictionary = {}
var players_variant: Variant = data.get("players", [])
if players_variant is Array:
for player_variant in players_variant:
if not (player_variant is Dictionary):
continue
var player_data: Dictionary = player_variant
var user_id := str(player_data.get("userId", "")).strip_edges()
if user_id.is_empty() or _is_current_user(user_id, str(player_data.get("username", ""))):
continue
seen_user_ids[user_id] = true
var remote_player := _ensure_remote_player(user_id, player_data)
var position_variant: Variant = player_data.get("position", null)
if remote_player != null and position_variant is Vector2 and remote_player.has_method("update_position"):
remote_player.call("update_position", position_variant as Vector2)
for user_id_variant in _remote_players.keys().duplicate():
var user_id := str(user_id_variant)
if seen_user_ids.has(user_id):
continue
var remote_player := _remote_players[user_id] as Node
_remote_players.erase(user_id)
if is_instance_valid(remote_player):
remote_player.queue_free()
for user_id_variant in _pending_remote_players.keys().duplicate():
var pendingUserId := str(user_id_variant)
if not seen_user_ids.has(pendingUserId):
_pending_remote_players.erase(pendingUserId)
func _on_remote_player_position_updated(data: Dictionary) -> void:
if not _is_event_for_current_map(data):
return
var user_id := str(data.get("userId", "")).strip_edges()
if user_id.is_empty():
return
if _is_current_user(user_id, str(data.get("username", ""))):
return
var position_variant: Variant = data.get("position", null)
if not (position_variant is Vector2):
return
var remote_player := _ensure_remote_player(user_id, data)
if remote_player != null and remote_player.has_method("update_position"):
remote_player.call("update_position", position_variant as Vector2)
func _on_remote_player_left(data: Dictionary) -> void:
if not _is_event_for_current_map(data):
return
var user_id := str(data.get("userId", "")).strip_edges()
if user_id.is_empty():
return
_pending_remote_players.erase(user_id)
if not _remote_players.has(user_id):
return
var remote_player := _remote_players[user_id] as Node
_remote_players.erase(user_id)
if is_instance_valid(remote_player):
remote_player.queue_free()
func _on_chat_message_received(data: Dictionary) -> void:
if not bool(data.get("show_bubble", false)):
return
if not _settings_bool("show_chat_bubbles", true):
return
var content := str(data.get("content", "")).strip_edges()
if content.is_empty():
return
var target := _resolve_chat_bubble_target(data)
if target == null:
return
_show_chat_bubble(target, content)
func _ensure_remote_player(user_id: String, data: Dictionary) -> Node2D:
if _is_current_user(user_id, str(data.get("username", ""))):
return null
var appearanceManager := get_node_or_null("/root/AppearanceManager")
var skinId := str(data.get("skin_id", data.get("skinId", ""))).strip_edges()
var skinAssetVariant: Variant = data.get("skin_asset", data.get("skinAsset", {}))
var skinAsset: Dictionary = skinAssetVariant if skinAssetVariant is Dictionary else {}
if appearanceManager != null and appearanceManager.has_method("is_remote_skin_ready"):
if not bool(appearanceManager.call("is_remote_skin_ready", skinId, skinAsset)):
_queue_pending_remote_player(user_id, data)
if _remote_players.has(user_id):
var loadingPlayer := _remote_players[user_id] as Node2D
if is_instance_valid(loadingPlayer):
loadingPlayer.hide()
if appearanceManager.has_method("request_remote_skin"):
appearanceManager.call("request_remote_skin", skinId, skinAsset)
return null
if _remote_players.has(user_id):
var existing_player := _remote_players[user_id] as Node2D
if is_instance_valid(existing_player):
_apply_remote_player_metadata(existing_player, data)
existing_player.show()
return existing_player
if _remote_players_root == null:
return null
var remote_player := REMOTE_PLAYER_SCENE.instantiate() as Node2D
if remote_player == null:
return null
remote_player.name = "RemotePlayer_%s" % user_id
remote_player.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
_remote_players_root.add_child(remote_player)
_remote_players[user_id] = remote_player
if remote_player.has_method("setup"):
var setup_data := data.duplicate(true)
if not setup_data.has("position"):
setup_data["position"] = {"x": _local_player.global_position.x if _local_player != null else 0.0, "y": _local_player.global_position.y if _local_player != null else 0.0}
remote_player.call("setup", setup_data)
return remote_player
func _queue_pending_remote_player(userId: String, data: Dictionary) -> void:
var merged: Dictionary = (_pending_remote_players.get(userId, {}) as Dictionary).duplicate(true)
for key in data.keys():
merged[key] = data[key]
_pending_remote_players[userId] = merged
func _on_remote_skin_ready(data: Dictionary) -> void:
var skinId := str(data.get("skin_id", "")).strip_edges()
if skinId.is_empty():
return
for userIdVariant in _pending_remote_players.keys().duplicate():
var userId := str(userIdVariant)
var pendingData: Dictionary = _pending_remote_players.get(userId, {})
var pendingSkinId := str(pendingData.get("skin_id", pendingData.get("skinId", ""))).strip_edges()
if pendingSkinId != skinId:
continue
_pending_remote_players.erase(userId)
var remotePlayer := _ensure_remote_player(userId, pendingData)
var positionVariant: Variant = pendingData.get("position", null)
if remotePlayer != null and positionVariant is Vector2 and remotePlayer.has_method("update_position"):
remotePlayer.call("update_position", positionVariant as Vector2)
func _on_remote_skin_failed(data: Dictionary) -> void:
var skinId := str(data.get("skin_id", "")).strip_edges()
if skinId.is_empty():
return
for userIdVariant in _pending_remote_players.keys().duplicate():
var userId := str(userIdVariant)
var pendingData: Dictionary = _pending_remote_players.get(userId, {})
var pendingSkinId := str(pendingData.get("skin_id", pendingData.get("skinId", ""))).strip_edges()
if pendingSkinId != skinId:
continue
_pending_remote_players.erase(userId)
var fallbackData := pendingData.duplicate(true)
fallbackData["skin_id"] = ""
fallbackData["skinId"] = ""
fallbackData.erase("skin_asset")
fallbackData.erase("skinAsset")
var remotePlayer := _ensure_remote_player(userId, fallbackData)
var positionVariant: Variant = pendingData.get("position", null)
if remotePlayer != null and positionVariant is Vector2 and remotePlayer.has_method("update_position"):
remotePlayer.call("update_position", positionVariant as Vector2)
func _find_private_chat_target() -> Node2D:
if _local_player == null or _remote_players.is_empty():
return null
var local_position := _local_player.global_position
var facing_direction := _get_local_facing_direction()
var best_forward_target: Node2D = null
var best_forward_distance := INF
var best_any_target: Node2D = null
var best_any_distance := INF
for remote_player_variant in _remote_players.values():
var remote_player := remote_player_variant as Node2D
if not is_instance_valid(remote_player):
continue
var offset := remote_player.global_position - local_position
var distance := offset.length()
if distance > PRIVATE_CHAT_INTERACTION_DISTANCE:
continue
if distance < best_any_distance:
best_any_distance = distance
best_any_target = remote_player
if distance <= 0.01:
continue
var forward_dot := facing_direction.dot(offset.normalized())
if forward_dot >= PRIVATE_CHAT_FORWARD_DOT_THRESHOLD and distance < best_forward_distance:
best_forward_distance = distance
best_forward_target = remote_player
return best_forward_target if best_forward_target != null else best_any_target
func _get_local_facing_direction() -> Vector2:
if _local_player != null:
var last_direction := str(_local_player.get("lastDirection"))
match last_direction:
"up":
return Vector2.UP
"down":
return Vector2.DOWN
"left":
return Vector2.LEFT
"right":
return Vector2.RIGHT
_:
pass
return Vector2.DOWN
func _apply_remote_player_metadata(remote_player: Node2D, data: Dictionary) -> void:
var username := str(data.get("username", "")).strip_edges()
if not username.is_empty():
remote_player.set("username", username)
if data.has("skin_id") or data.has("skinId") or data.has("skin_asset") or data.has("skinAsset") or data.has("avatar_id") or data.has("avatarId") or data.has("cafe_companion") or data.has("cafeCompanion"):
if remote_player.has_method("setup"):
remote_player.call("setup", data)
func _resolve_chat_bubble_target(data: Dictionary) -> Node2D:
var from_user := str(data.get("from_user", data.get("username", ""))).strip_edges()
var user_id := str(data.get("userId", data.get("user_id", ""))).strip_edges()
var is_self := bool(data.get("is_self", false))
if is_self or (not user_id.is_empty() and _is_current_user(user_id, from_user)) or _is_current_username(from_user):
return _local_player
if not user_id.is_empty() and _remote_players.has(user_id):
var remote_by_id := _remote_players[user_id] as Node2D
if is_instance_valid(remote_by_id):
return remote_by_id
if from_user.is_empty():
return null
for remote_player_variant in _remote_players.values():
var remote_player := remote_player_variant as Node2D
if not is_instance_valid(remote_player):
continue
var remote_username := str(remote_player.get("username")).strip_edges()
if not remote_username.is_empty() and remote_username == from_user:
return remote_player
return null
func _show_chat_bubble(target: Node2D, text: String) -> void:
var bubble := CHAT_BUBBLE_SCENE.instantiate() as Control
if bubble == null:
return
var bubble_layer := _get_chat_bubble_layer()
bubble_layer.add_child(bubble)
if bubble.has_method("set_text"):
bubble.call("set_text", text, target, CHAT_BUBBLE_TARGET_OFFSET)
func _get_chat_bubble_layer() -> CanvasLayer:
return _get_or_create_canvas_layer(CHAT_BUBBLE_LAYER_NAME, 20)
func _get_or_create_canvas_layer(layerName: String, layerIndex: int) -> CanvasLayer:
var root := get_tree().root
var layer := root.get_node_or_null(layerName) as CanvasLayer
if layer != null:
return layer
layer = CanvasLayer.new()
layer.name = layerName
layer.layer = layerIndex
root.add_child(layer)
return layer
func _get_map_id() -> String:
var configured_map_id := map_id.strip_edges()
return configured_map_id if not configured_map_id.is_empty() else DEFAULT_MAP_ID
func _is_event_for_current_map(data: Dictionary) -> bool:
var event_map_id := str(data.get("mapId", data.get("map_id", ""))).strip_edges()
return event_map_id.is_empty() or event_map_id == _get_map_id()
func _is_current_user(user_id: String, username: String = "") -> bool:
var authManager := get_node_or_null("/root/AuthManager")
if authManager == null:
return false
var user: Dictionary = authManager.call("get_current_user")
var current_id := str(user.get("id", user.get("userId", user.get("user_id", "")))).strip_edges()
if not current_id.is_empty() and user_id == current_id:
return true
var current_username := str(user.get("username", "")).strip_edges()
return not current_username.is_empty() and not username.strip_edges().is_empty() and current_username == username.strip_edges()
func _is_current_username(username: String) -> bool:
var normalized_username := username.strip_edges()
if normalized_username.is_empty():
return false
var authManager := get_node_or_null("/root/AuthManager")
if authManager != null:
var user: Dictionary = authManager.call("get_current_user")
var current_username := str(user.get("username", "")).strip_edges()
if not current_username.is_empty() and current_username == normalized_username:
return true
var chatManager := _get_chat_manager()
if chatManager != null:
var chat_username := str(chatManager.get("_current_username")).strip_edges()
if not chat_username.is_empty() and chat_username == normalized_username:
return true
return false
func _settings_bool(key: String, defaultValue: bool) -> bool:
var settingsManager := _get_settings_manager()
if settingsManager != null and settingsManager.has_method("get_bool"):
return bool(settingsManager.call("get_bool", key))
return defaultValue