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:
2026-09-08 21:37:43 +08:00
parent 175621f66c
commit fc6c3c1bd3
87 changed files with 4546 additions and 506 deletions

View File

@@ -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", {})))),
}