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:
@@ -7,6 +7,7 @@ extends Node
|
||||
# ============================================================================
|
||||
|
||||
const REMOTE_PLAYER_SCENE: PackedScene = preload("res://scenes/characters/remote_player.tscn")
|
||||
const NETWORK_NPC_SCENE: PackedScene = preload("res://scenes/characters/network_npc.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
|
||||
@@ -19,19 +20,26 @@ 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")
|
||||
@export var network_npcs_root_path: NodePath = NodePath("../Characters/Npcs")
|
||||
|
||||
var _local_player: Node2D
|
||||
var _remote_players_root: Node2D
|
||||
var _network_npcs_root: Node2D
|
||||
var _remote_players: Dictionary = {}
|
||||
var _network_npcs: Dictionary = {}
|
||||
var _pending_remote_players: Dictionary = {}
|
||||
var _last_sent_position: Vector2 = Vector2.INF
|
||||
var _last_sent_at_msec: int = 0
|
||||
var _last_sent_direction: String = ""
|
||||
var _last_sent_movement_state: String = ""
|
||||
var _movement_sequence: 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
|
||||
_network_npcs_root = get_node_or_null(network_npcs_root_path) as Node2D
|
||||
|
||||
if _local_player == null:
|
||||
push_warning("MapMultiplayerController: local player not found.")
|
||||
@@ -46,6 +54,9 @@ func _ready() -> void:
|
||||
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.NPC_SNAPSHOT_READY, _on_npc_snapshot_ready, self)
|
||||
eventSystem.call("connect_event", EventNames.NPC_ACTION_STARTED, _on_npc_action_started, self)
|
||||
eventSystem.call("connect_event", EventNames.NPC_ACTION_COMPLETED, _on_npc_action_completed, 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)
|
||||
@@ -78,6 +89,9 @@ func _exit_tree() -> void:
|
||||
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.NPC_SNAPSHOT_READY, _on_npc_snapshot_ready, self)
|
||||
eventSystem.call("disconnect_event", EventNames.NPC_ACTION_STARTED, _on_npc_action_started, self)
|
||||
eventSystem.call("disconnect_event", EventNames.NPC_ACTION_COMPLETED, _on_npc_action_completed, 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)
|
||||
@@ -175,7 +189,9 @@ func _on_chat_login_success(_data: Dictionary) -> void:
|
||||
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)
|
||||
var direction := str(data.get("direction", _get_local_direction()))
|
||||
var movementState := str(data.get("movement_state", "walk"))
|
||||
_send_position(position_variant as Vector2, direction, movementState)
|
||||
|
||||
func _send_world_ready() -> void:
|
||||
if _local_player == null:
|
||||
@@ -183,13 +199,16 @@ func _send_world_ready() -> void:
|
||||
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)
|
||||
chatManager.call("mark_world_ready", _get_map_id(), _local_player.global_position, _get_local_direction(), "idle", _movement_sequence)
|
||||
|
||||
func _send_position(position: Vector2, force: bool = false) -> void:
|
||||
func _send_position(position: Vector2, direction: String, movementState: String, force: bool = false) -> void:
|
||||
var normalizedDirection := _normalize_direction(direction)
|
||||
var normalizedMovementState := _normalize_movement_state(movementState)
|
||||
var stateChanged := normalizedDirection != _last_sent_direction or normalizedMovementState != _last_sent_movement_state
|
||||
var now := Time.get_ticks_msec()
|
||||
if not force and now - _last_sent_at_msec < int(POSITION_SEND_INTERVAL * 1000.0):
|
||||
if not force and not stateChanged 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:
|
||||
if not force and not stateChanged and _last_sent_position != Vector2.INF and _last_sent_position.distance_to(position) < MIN_POSITION_DELTA:
|
||||
return
|
||||
|
||||
var chatManager := _get_chat_manager()
|
||||
@@ -200,7 +219,10 @@ func _send_position(position: Vector2, force: bool = false) -> void:
|
||||
|
||||
_last_sent_position = position
|
||||
_last_sent_at_msec = now
|
||||
chatManager.call("update_player_position", position.x, position.y, _get_map_id())
|
||||
_last_sent_direction = normalizedDirection
|
||||
_last_sent_movement_state = normalizedMovementState
|
||||
_movement_sequence += 1
|
||||
chatManager.call("update_player_position", position.x, position.y, _get_map_id(), normalizedDirection, normalizedMovementState, _movement_sequence)
|
||||
|
||||
func _on_remote_player_joined(data: Dictionary) -> void:
|
||||
if not _is_event_for_current_map(data):
|
||||
@@ -210,8 +232,8 @@ func _on_remote_player_joined(data: Dictionary) -> void:
|
||||
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)
|
||||
if remote_player != null and position_variant is Vector2:
|
||||
_update_remote_player_position(remote_player, position_variant as Vector2, data)
|
||||
|
||||
func _on_remote_players_snapshot_ready(data: Dictionary) -> void:
|
||||
if not _is_event_for_current_map(data):
|
||||
@@ -230,8 +252,8 @@ func _on_remote_players_snapshot_ready(data: Dictionary) -> void:
|
||||
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)
|
||||
if remote_player != null and position_variant is Vector2:
|
||||
_update_remote_player_position(remote_player, position_variant as Vector2, player_data)
|
||||
|
||||
for user_id_variant in _remote_players.keys().duplicate():
|
||||
var user_id := str(user_id_variant)
|
||||
@@ -246,6 +268,70 @@ func _on_remote_players_snapshot_ready(data: Dictionary) -> void:
|
||||
if not seen_user_ids.has(pendingUserId):
|
||||
_pending_remote_players.erase(pendingUserId)
|
||||
|
||||
func _on_npc_snapshot_ready(data: Dictionary) -> void:
|
||||
if not _is_event_for_current_map(data):
|
||||
return
|
||||
if _network_npcs_root == null:
|
||||
return
|
||||
|
||||
var seenNpcIds: Dictionary = {}
|
||||
var npcsVariant: Variant = data.get("npcs", [])
|
||||
if npcsVariant is Array:
|
||||
for npcVariant in npcsVariant:
|
||||
if not (npcVariant is Dictionary):
|
||||
continue
|
||||
var npcData: Dictionary = npcVariant
|
||||
var npcId := str(npcData.get("npc_id", npcData.get("npcId", ""))).strip_edges()
|
||||
if npcId.is_empty():
|
||||
continue
|
||||
seenNpcIds[npcId] = true
|
||||
var networkNpc := _ensure_network_npc(npcId)
|
||||
if networkNpc != null and networkNpc.has_method("apply_snapshot"):
|
||||
networkNpc.call("apply_snapshot", npcData)
|
||||
|
||||
for npcIdVariant in _network_npcs.keys().duplicate():
|
||||
var npcId := str(npcIdVariant)
|
||||
if seenNpcIds.has(npcId):
|
||||
continue
|
||||
var networkNpc := _network_npcs.get(npcId) as Node
|
||||
_network_npcs.erase(npcId)
|
||||
if is_instance_valid(networkNpc):
|
||||
networkNpc.queue_free()
|
||||
|
||||
func _ensure_network_npc(npcId: String) -> Node2D:
|
||||
if _network_npcs.has(npcId):
|
||||
var existingNpc := _network_npcs.get(npcId) as Node2D
|
||||
if is_instance_valid(existingNpc):
|
||||
return existingNpc
|
||||
_network_npcs.erase(npcId)
|
||||
|
||||
var networkNpc := NETWORK_NPC_SCENE.instantiate() as Node2D
|
||||
if networkNpc == null:
|
||||
return null
|
||||
networkNpc.name = "NetworkNpc_%s" % npcId
|
||||
networkNpc.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
||||
_network_npcs_root.add_child(networkNpc)
|
||||
_network_npcs[npcId] = networkNpc
|
||||
return networkNpc
|
||||
|
||||
func _on_npc_action_started(data: Dictionary) -> void:
|
||||
_apply_npc_action(data, "apply_action_started")
|
||||
|
||||
func _on_npc_action_completed(data: Dictionary) -> void:
|
||||
_apply_npc_action(data, "apply_action_completed")
|
||||
|
||||
func _apply_npc_action(data: Dictionary, methodName: String) -> void:
|
||||
if not _is_event_for_current_map(data):
|
||||
return
|
||||
var npcId := str(data.get("npc_id", data.get("npcId", ""))).strip_edges()
|
||||
if npcId.is_empty():
|
||||
return
|
||||
var networkNpc := _network_npcs.get(npcId) as Node
|
||||
if networkNpc == null:
|
||||
networkNpc = _ensure_network_npc(npcId)
|
||||
if networkNpc != null and networkNpc.has_method(methodName):
|
||||
networkNpc.call(methodName, data)
|
||||
|
||||
func _on_remote_player_position_updated(data: Dictionary) -> void:
|
||||
if not _is_event_for_current_map(data):
|
||||
return
|
||||
@@ -260,8 +346,8 @@ func _on_remote_player_position_updated(data: Dictionary) -> void:
|
||||
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)
|
||||
if remote_player != null:
|
||||
_update_remote_player_position(remote_player, position_variant as Vector2, data)
|
||||
|
||||
func _on_remote_player_left(data: Dictionary) -> void:
|
||||
if not _is_event_for_current_map(data):
|
||||
@@ -357,8 +443,8 @@ func _on_remote_skin_ready(data: Dictionary) -> void:
|
||||
_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)
|
||||
if remotePlayer != null and positionVariant is Vector2:
|
||||
_update_remote_player_position(remotePlayer, positionVariant as Vector2, pendingData)
|
||||
|
||||
func _on_remote_skin_failed(data: Dictionary) -> void:
|
||||
var skinId := str(data.get("skin_id", "")).strip_edges()
|
||||
@@ -372,14 +458,14 @@ func _on_remote_skin_failed(data: Dictionary) -> void:
|
||||
continue
|
||||
_pending_remote_players.erase(userId)
|
||||
var fallbackData := pendingData.duplicate(true)
|
||||
fallbackData["skin_id"] = ""
|
||||
fallbackData["skinId"] = ""
|
||||
fallbackData["skin_id"] = "classic_whale"
|
||||
fallbackData["skinId"] = "classic_whale"
|
||||
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)
|
||||
if remotePlayer != null and positionVariant is Vector2:
|
||||
_update_remote_player_position(remotePlayer, positionVariant as Vector2, pendingData)
|
||||
|
||||
func _find_private_chat_target() -> Node2D:
|
||||
if _local_player == null or _remote_players.is_empty():
|
||||
@@ -436,9 +522,31 @@ func _apply_remote_player_metadata(remote_player: Node2D, data: Dictionary) -> v
|
||||
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)
|
||||
if remote_player.has_method("update_metadata"):
|
||||
remote_player.call("update_metadata", data)
|
||||
|
||||
func _update_remote_player_position(remotePlayer: Node2D, position: Vector2, data: Dictionary) -> void:
|
||||
if not remotePlayer.has_method("update_position"):
|
||||
return
|
||||
remotePlayer.call(
|
||||
"update_position",
|
||||
position,
|
||||
str(data.get("direction", "")),
|
||||
str(data.get("movement_state", data.get("movementState", "walk"))),
|
||||
int(data.get("sequence", -1))
|
||||
)
|
||||
|
||||
func _get_local_direction() -> String:
|
||||
if _local_player != null:
|
||||
return _normalize_direction(str(_local_player.get("lastDirection")))
|
||||
return "down"
|
||||
|
||||
func _normalize_direction(value: String) -> String:
|
||||
var normalized := value.strip_edges().to_lower()
|
||||
return normalized if normalized in ["down", "up", "right", "left"] else "down"
|
||||
|
||||
func _normalize_movement_state(value: String) -> String:
|
||||
return "walk" if value.strip_edges().to_lower() == "walk" else "idle"
|
||||
|
||||
func _resolve_chat_bubble_target(data: Dictionary) -> Node2D:
|
||||
var from_user := str(data.get("from_user", data.get("username", ""))).strip_edges()
|
||||
|
||||
Reference in New Issue
Block a user