10 Commits

Author SHA1 Message Date
ANG-Server
fc872fe8af refactor: harden client runtime and exports 2026-07-23 00:59:00 +08:00
ANG-Server
5d2339a29b fix: separate wall and floor decor placement 2026-07-22 14:20:18 +08:00
ANG-Server
ce896fc1e0 fix: improve personal space layout editing 2026-07-22 14:14:43 +08:00
ANG-Server
d60444d2eb feat: polish personal space editor 2026-07-22 13:41:28 +08:00
ANG-Server
e3accf6b71 feat: add in-game social test lab 2026-07-22 12:16:31 +08:00
ANG-Server
9a3248abd6 feat: add taskbook onboarding UI 2026-07-22 00:45:59 +08:00
ANG-Server
bb91ccea21 docs: document frontend exports 2026-07-22 00:45:16 +08:00
ANG-Server
aa19f157f2 fix: align interaction anchors with collision areas 2026-07-21 23:47:24 +08:00
ANG-Server
74a0fb8309 refactor: unify interactable components 2026-07-21 23:23:26 +08:00
ANG-Server
9dee47492c feat: add nearby social interactions 2026-07-21 23:07:31 +08:00
76 changed files with 4822 additions and 933 deletions

13
.gitattributes vendored
View File

@@ -1,2 +1,15 @@
# Normalize EOL for all files that Git considers text files. # Normalize EOL for all files that Git considers text files.
* text=auto eol=lf * text=auto eol=lf
# Large binary assets use Git LFS on their next modification. Existing history is
# intentionally not rewritten by this change.
*.png filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text
*.jpeg filter=lfs diff=lfs merge=lfs -text
*.webp filter=lfs diff=lfs merge=lfs -text
*.ttf filter=lfs diff=lfs merge=lfs -text
*.ttc filter=lfs diff=lfs merge=lfs -text
*.otf filter=lfs diff=lfs merge=lfs -text
*.wav filter=lfs diff=lfs merge=lfs -text
*.ogg filter=lfs diff=lfs merge=lfs -text
*.mp3 filter=lfs diff=lfs merge=lfs -text

View File

@@ -16,6 +16,17 @@ WhaleTown V2 前端是基于 Godot 4.6 的 2D 多人小镇客户端。
2. 用 Godot 打开 `project.godot` 2. 用 Godot 打开 `project.godot`
3. 运行默认主场景。 3. 运行默认主场景。
## 导出
仓库包含 Web 和 Linux 导出预设。安装 Godot 4.6 导出模板后可执行:
```bash
godot --headless --path . --export-release Web build/web/index.html
godot --headless --path . --export-release "Linux/X11" build/linux/WhaleTown-V2.x86_64
```
Web 产物是静态文件,必须通过 HTTP/HTTPS 服务器发布,不要直接用 `file://` 打开。
默认连接 WhaleTown 生产 API 和 WebSocket 服务。本地联调时可使用以下环境变量覆盖: 默认连接 WhaleTown 生产 API 和 WebSocket 服务。本地联调时可使用以下环境变量覆盖:
- `WHALETOWN_API_BASE_URL` - `WHALETOWN_API_BASE_URL`

View File

@@ -82,6 +82,8 @@ const CHAT_LOGIN_FAILED = "chat_login_failed"
const CHAT_PRIVATE_TARGET_SELECTED = "chat_private_target_selected" const CHAT_PRIVATE_TARGET_SELECTED = "chat_private_target_selected"
const CHAT_FRIEND_SELECTED = "chat_friend_selected" const CHAT_FRIEND_SELECTED = "chat_friend_selected"
const CHAT_FRIENDS_UPDATED = "chat_friends_updated" const CHAT_FRIENDS_UPDATED = "chat_friends_updated"
const SOCIAL_NOTIFICATION_RECEIVED = "social_notification_received"
const SOCIAL_PROFILE_UPDATED = "social_profile_updated"
# ============================================================================ # ============================================================================
# 咖啡店陪伴机器人事件 # 咖啡店陪伴机器人事件
@@ -105,6 +107,8 @@ const HUD_FRIEND_LIST_TOGGLE = "hud_friend_list_toggle"
const HUD_SETTINGS_REQUESTED = "hud_settings_requested" const HUD_SETTINGS_REQUESTED = "hud_settings_requested"
const HUD_BACKPACK_TOGGLE = "hud_backpack_toggle" const HUD_BACKPACK_TOGGLE = "hud_backpack_toggle"
const HUD_MAP_TOGGLE = "hud_map_toggle" const HUD_MAP_TOGGLE = "hud_map_toggle"
const HUD_TASK_BOOK_REQUESTED = "hud_task_book_requested"
const HUD_ADMIN_TEST_LAB_REQUESTED = "hud_admin_test_lab_requested"
# ============================================================================ # ============================================================================
# 商城事件 # 商城事件

View File

@@ -0,0 +1,54 @@
extends RefCounted
# 统一转换后端聊天载荷,避免 WebSocket 实时消息和 HTTP 历史消息
# 各自维护一套时间戳兼容规则。
static func normalize_history(messages: Array, current_username: String) -> Array[Dictionary]:
var normalized: Array[Dictionary] = []
for message_variant: Variant in messages:
if not (message_variant is Dictionary):
continue
var message: Dictionary = message_variant
var sender := str(message.get("sender", message.get("from_user", "")))
normalized.append({
"from_user": sender,
"from_user_id": str(message.get("fromUserId", message.get("from_user_id", ""))),
"content": str(message.get("content", "")),
"timestamp": parse_timestamp(message.get("timestamp", 0.0)),
"is_self": sender == current_username,
"scope": str(message.get("scope", "local")),
"show_bubble": bool(message.get("bubble", false)),
"is_history": true,
})
return normalized
static func parse_timestamp(timestamp_raw: Variant) -> float:
if typeof(timestamp_raw) == TYPE_INT or typeof(timestamp_raw) == TYPE_FLOAT:
var numeric_timestamp := float(timestamp_raw)
return numeric_timestamp if numeric_timestamp > 0.0 else Time.get_unix_time_from_system()
var timestamp_text := str(timestamp_raw)
if timestamp_text.strip_edges().is_empty():
return Time.get_unix_time_from_system()
var numeric_regex := RegEx.new()
numeric_regex.compile("^\\s*-?\\d+(?:\\.\\d+)?\\s*$")
if numeric_regex.search(timestamp_text) != null:
var parsed_numeric := float(timestamp_text)
return parsed_numeric if parsed_numeric > 0.0 else Time.get_unix_time_from_system()
var iso_regex := RegEx.new()
iso_regex.compile("(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})")
var match_result := iso_regex.search(timestamp_text)
if match_result == null:
return Time.get_unix_time_from_system()
var utc_datetime := {
"year": int(match_result.get_string(1)),
"month": int(match_result.get_string(2)),
"day": int(match_result.get_string(3)),
"hour": int(match_result.get_string(4)),
"minute": int(match_result.get_string(5)),
"second": int(match_result.get_string(6)),
}
return Time.get_unix_time_from_datetime_dict(utc_datetime)

View File

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

View File

@@ -0,0 +1,100 @@
class_name InteractableComponent
extends Node
# 可挂载于任意 Node2D 宿主的交互组件。
# 它统一负责交互锚点、距离过滤、白圈位置和静态动作;复杂宿主可通过
# build_interaction_actions(component, player) 返回多个 InteractionAction。
const GROUP: StringName = &"whaletown_interactable_component"
@export_category("Interaction")
@export var interaction_id: String = ""
@export var interaction_title: String = ""
@export var interaction_priority: int = 100
@export var interaction_distance: float = 150.0
@export var activation_method: StringName = &""
@export var show_marker: bool = true
@export_category("Anchor")
@export var anchor_path: NodePath = NodePath("")
var _host: Node2D
func _ready() -> void:
_host = get_parent() as Node2D
if _host == null:
push_error("InteractableComponent 必须挂在 Node2D 宿主下:%s" % get_path())
return
add_to_group(GROUP)
func get_actions(player: Node2D) -> Array[InteractionAction]:
var actions: Array[InteractionAction] = []
if player == null or not is_interaction_active():
return actions
var distance: float = get_anchor_position().distance_to(player.global_position)
if distance > interaction_distance:
return actions
if _host != null and _host.has_method("build_interaction_actions"):
var actions_variant: Variant = _host.call("build_interaction_actions", self, player)
if actions_variant is Array:
for action_variant: Variant in actions_variant as Array:
if action_variant is InteractionAction:
var action: InteractionAction = action_variant as InteractionAction
if action.is_valid():
action.distance = distance
actions.append(action)
return actions
var default_action: InteractionAction = _create_default_action()
if default_action != null:
default_action.distance = distance
actions.append(default_action)
return actions
func get_marker_positions() -> Array[Vector2]:
if not show_marker or not is_interaction_active():
return []
return [get_anchor_position()]
func get_anchor_position() -> Vector2:
var anchor: Node2D = _resolve_anchor()
if anchor == null:
return Vector2.ZERO
var collision_shape: CollisionShape2D = _first_enabled_collision_shape(anchor)
return collision_shape.global_position if collision_shape != null else anchor.global_position
func is_interaction_active() -> bool:
if _host == null:
return false
if _host.has_method("is_interaction_active"):
return bool(_host.call("is_interaction_active", self))
return true
func _create_default_action() -> InteractionAction:
if _host == null or activation_method.is_empty() or interaction_id.strip_edges().is_empty() or interaction_title.strip_edges().is_empty():
return null
if not _host.has_method(activation_method):
push_warning("InteractableComponent: %s 不存在方法 %s" % [_host.get_path(), activation_method])
return null
return InteractionAction.create(interaction_id, interaction_title, interaction_priority, Callable(_host, activation_method))
func _resolve_anchor() -> Node2D:
if _host == null:
return null
if not anchor_path.is_empty():
var explicit_anchor: Node2D = _host.get_node_or_null(anchor_path) as Node2D
if explicit_anchor != null:
return explicit_anchor
return _host
func _first_enabled_collision_shape(anchor: Node) -> CollisionShape2D:
var direct_shape: CollisionShape2D = anchor as CollisionShape2D
if direct_shape != null and not direct_shape.disabled and direct_shape.shape != null:
return direct_shape
for child: Node in anchor.get_children():
var collision_shape: CollisionShape2D = child as CollisionShape2D
if collision_shape != null and not collision_shape.disabled and collision_shape.shape != null:
return collision_shape
for child: Node in anchor.get_children():
var nested_shape: CollisionShape2D = _first_enabled_collision_shape(child)
if nested_shape != null:
return nested_shape
return null

View File

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

View File

@@ -0,0 +1,20 @@
class_name InteractionAction
extends RefCounted
# 交互管理器使用的强类型动作数据,避免各交互物品以 Dictionary 约定字段。
var id: String = ""
var title: String = ""
var priority: int = 100
var distance: float = INF
var activate: Callable = Callable()
static func create(action_id: String, action_title: String, action_priority: int, callback: Callable) -> InteractionAction:
var action: InteractionAction = InteractionAction.new()
action.id = action_id
action.title = action_title
action.priority = action_priority
action.activate = callback
return action
func is_valid() -> bool:
return not id.strip_edges().is_empty() and not title.strip_edges().is_empty() and activate.is_valid()

View File

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

View File

@@ -21,21 +21,24 @@ func _exit_tree() -> void:
request.queue_free() request.queue_free()
_activeRequests.clear() _activeRequests.clear()
func get_json(endpoint: String, callback: Callable, authenticated: bool = true) -> void: func get_json(endpoint: String, callback: Callable, authenticated: bool = true, timeout: float = REQUEST_TIMEOUT) -> void:
request_json(endpoint, {}, callback, HTTPClient.METHOD_GET, authenticated) request_json(endpoint, {}, callback, HTTPClient.METHOD_GET, authenticated, timeout)
func post_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true) -> void: func post_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true, timeout: float = REQUEST_TIMEOUT) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_POST, authenticated) request_json(endpoint, payload, callback, HTTPClient.METHOD_POST, authenticated, timeout)
func patch_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true) -> void: func patch_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true, timeout: float = REQUEST_TIMEOUT) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_PATCH, authenticated) request_json(endpoint, payload, callback, HTTPClient.METHOD_PATCH, authenticated, timeout)
func put_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true) -> void: func put_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true, timeout: float = REQUEST_TIMEOUT) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_PUT, authenticated) request_json(endpoint, payload, callback, HTTPClient.METHOD_PUT, authenticated, timeout)
func request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_GET, authenticated: bool = true) -> void: func delete_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true, timeout: float = REQUEST_TIMEOUT) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_DELETE, authenticated, timeout)
func request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_GET, authenticated: bool = true, timeout: float = REQUEST_TIMEOUT) -> void:
var request := HTTPRequest.new() var request := HTTPRequest.new()
request.timeout = REQUEST_TIMEOUT request.timeout = timeout
add_child(request) add_child(request)
_activeRequests.append(request) _activeRequests.append(request)
@@ -97,6 +100,9 @@ func _handle_response(endpoint: String, result: int, responseCode: int, body: Pa
"response_code": responseCode, "response_code": responseCode,
"error_code": str(response.get("error_code", "")) "error_code": str(response.get("error_code", ""))
} }
for key in response.keys():
if not errorInfo.has(key):
errorInfo[key] = response[key]
request_failed.emit(endpoint, str(errorInfo.get("message", "请求失败"))) request_failed.emit(endpoint, str(errorInfo.get("message", "请求失败")))
callback.call(false, response, errorInfo) callback.call(false, response, errorInfo)

View File

@@ -18,31 +18,25 @@ signal profile_update_succeeded(profile: Dictionary)
signal profile_update_failed(message: String) signal profile_update_failed(message: String)
signal logout_completed() signal logout_completed()
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd") const SecureSessionStore = preload("res://_Core/security/SecureSessionStore.gd")
const DEFAULT_AUTH_CONFIG_PATH: String = "user://auth.cfg" const DEFAULT_AUTH_CONFIG_PATH: String = "user://auth.secure"
const REQUEST_TIMEOUT: float = 12.0 const LEGACY_AUTH_CONFIG_PATH: String = "user://auth.cfg"
var _access_token: String = "" var _access_token: String = ""
var _refresh_token: String = "" var _refresh_token: String = ""
var _current_user: Dictionary = {} var _current_user: Dictionary = {}
var _current_profile: Dictionary = {} var _current_profile: Dictionary = {}
var _active_requests: Array[HTTPRequest] = []
var _session_generation: int = 0 var _session_generation: int = 0
var _account_generation: int = 0 var _account_generation: int = 0
var _refresh_in_flight: bool = false var _refresh_in_flight: bool = false
var _auth_config_path: String = DEFAULT_AUTH_CONFIG_PATH var _auth_config_path: String = DEFAULT_AUTH_CONFIG_PATH
var _show_welcome_after_registration: bool = false
var _secure_session_store: RefCounted = SecureSessionStore.new()
func _ready() -> void: func _ready() -> void:
_load_cached_session() _load_cached_session()
func _exit_tree() -> void:
for request in _active_requests:
if is_instance_valid(request):
request.cancel_request()
request.queue_free()
_active_requests.clear()
func is_authenticated() -> bool: func is_authenticated() -> bool:
return not _access_token.strip_edges().is_empty() return not _access_token.strip_edges().is_empty()
@@ -70,6 +64,11 @@ func get_account_generation() -> int:
func get_auth_config_path() -> String: func get_auth_config_path() -> String:
return _auth_config_path return _auth_config_path
func consume_registration_welcome() -> bool:
var should_show: bool = _show_welcome_after_registration
_show_welcome_after_registration = false
return should_show
func login(identifier: String, password: String) -> void: func login(identifier: String, password: String) -> void:
var normalized_identifier := identifier.strip_edges() var normalized_identifier := identifier.strip_edges()
if normalized_identifier.is_empty(): if normalized_identifier.is_empty():
@@ -207,69 +206,30 @@ func refresh_session() -> void:
func logout() -> void: func logout() -> void:
_clear_session_memory() _clear_session_memory()
if FileAccess.file_exists(_auth_config_path): _secure_session_store.call("clear", _auth_config_path)
DirAccess.remove_absolute(_auth_config_path)
auth_state_changed.emit(false, {}) auth_state_changed.emit(false, {})
_emit_event(EventNames.AUTH_LOGOUT, {}) _emit_event(EventNames.AUTH_LOGOUT, {})
logout_completed.emit() logout_completed.emit()
call_deferred("_return_to_auth_scene") call_deferred("_return_to_auth_scene")
func _request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_POST, authenticated: bool = false, accountBound: bool = false) -> void: func _request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_POST, authenticated: bool = false, accountBound: bool = false) -> void:
var request := HTTPRequest.new() var api_client := get_node_or_null("/root/ApiClient")
request.timeout = REQUEST_TIMEOUT if api_client == null or not api_client.has_method("request_json"):
request.set_meta("account_bound", accountBound) callback.call(false, {}, {"message": "ApiClient 不可用"})
add_child(request) return
_active_requests.append(request) var request_generation := _account_generation
var requestGeneration := _account_generation api_client.call(
"request_json",
request.request_completed.connect(func(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: endpoint,
_active_requests.erase(request) payload,
request.queue_free() func(success: bool, response: Dictionary, error_info: Dictionary) -> void:
if accountBound and requestGeneration != _account_generation: if accountBound and request_generation != _account_generation:
return return
_handle_json_response(result, response_code, body, callback) callback.call(success, response, error_info),
method,
authenticated
) )
var url := "%s%s" % [NetworkConfig.get_api_base_url(), endpoint]
var headers := PackedStringArray(["Content-Type: application/json"])
if authenticated and not _access_token.strip_edges().is_empty():
headers.append("Authorization: Bearer %s" % _access_token)
var body := "" if method == HTTPClient.METHOD_GET else JSON.stringify(payload)
var err := request.request(url, headers, method, body)
if err != OK:
_active_requests.erase(request)
request.queue_free()
if not accountBound or requestGeneration == _account_generation:
callback.call(false, {}, {"message": "网络请求发送失败: %s" % error_string(err)})
func _handle_json_response(result: int, response_code: int, body: PackedByteArray, callback: Callable) -> void:
var body_text := body.get_string_from_utf8()
if result != HTTPRequest.RESULT_SUCCESS:
callback.call(false, {}, {"message": "网络请求失败: %s" % _http_result_to_string(result)})
return
var json := JSON.new()
if json.parse(body_text) != OK:
callback.call(false, {}, {"message": "服务器响应解析失败"})
return
var payload_variant: Variant = json.data
if not (payload_variant is Dictionary):
callback.call(false, {}, {"message": "服务器响应格式错误"})
return
var response: Dictionary = payload_variant
var success := response_code >= 200 and response_code < 300 and bool(response.get("success", true))
if success:
callback.call(true, response, {})
return
callback.call(false, response, {
"message": str(response.get("message", "请求失败")),
"response_code": response_code,
"error_code": str(response.get("error_code", ""))
})
func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary) -> void: func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary) -> void:
if not success: if not success:
login_failed.emit(str(error_info.get("message", "登录失败"))) login_failed.emit(str(error_info.get("message", "登录失败")))
@@ -297,6 +257,7 @@ func _on_register_response(success: bool, data: Dictionary, error_info: Dictiona
register_failed.emit("注册响应缺少 access_token") register_failed.emit("注册响应缺少 access_token")
return return
_show_welcome_after_registration = true
_emit_event(EventNames.AUTH_REGISTER_SUCCESS, { _emit_event(EventNames.AUTH_REGISTER_SUCCESS, {
"user": get_current_user() "user": get_current_user()
}) })
@@ -408,35 +369,34 @@ func _refresh_player_snapshot() -> void:
playerStateManager.call_deferred("refresh_snapshot") playerStateManager.call_deferred("refresh_snapshot")
func _save_cached_session() -> void: func _save_cached_session() -> void:
var config := ConfigFile.new() if _refresh_token.is_empty():
config.set_value("auth", "refresh_token", _refresh_token) _secure_session_store.call("clear", _auth_config_path)
config.set_value("auth", "access_token", _access_token) return
config.set_value("auth", "saved_at", Time.get_unix_time_from_system()) if not bool(_secure_session_store.call("save_refresh_token", _auth_config_path, _refresh_token)):
var err := config.save(_auth_config_path) push_warning("AuthManager: 当前平台不支持安全持久化,将仅保留本次会话")
if err != OK:
push_warning("AuthManager: 保存本地登录状态失败: %s" % error_string(err))
func _load_cached_session(emit_cached_state: bool = true) -> void: func _load_cached_session(emit_cached_state: bool = true) -> void:
if not FileAccess.file_exists(_auth_config_path): _migrate_legacy_cached_session()
return _refresh_token = str(_secure_session_store.call("load_refresh_token", _auth_config_path)).strip_edges()
_access_token = ""
var config := ConfigFile.new()
if config.load(_auth_config_path) != OK:
return
_refresh_token = str(config.get_value("auth", "refresh_token", "")).strip_edges()
_access_token = str(config.get_value("auth", "access_token", "")).strip_edges()
_current_user.clear() _current_user.clear()
_current_profile.clear() _current_profile.clear()
if _refresh_token.is_empty():
return
_session_generation += 1
_account_generation += 1
if emit_cached_state:
call_deferred("refresh_session")
if is_authenticated(): func _migrate_legacy_cached_session() -> void:
_session_generation += 1 if not FileAccess.file_exists(LEGACY_AUTH_CONFIG_PATH):
_account_generation += 1 return
if emit_cached_state and is_authenticated(): var config := ConfigFile.new()
call_deferred("_emit_cached_auth_state") if config.load(LEGACY_AUTH_CONFIG_PATH) == OK:
var legacy_refresh_token := str(config.get_value("auth", "refresh_token", "")).strip_edges()
func _emit_cached_auth_state() -> void: if not legacy_refresh_token.is_empty():
auth_state_changed.emit(true, get_current_user()) _secure_session_store.call("save_refresh_token", _auth_config_path, legacy_refresh_token)
DirAccess.remove_absolute(ProjectSettings.globalize_path(LEGACY_AUTH_CONFIG_PATH))
func _clear_session_memory() -> void: func _clear_session_memory() -> void:
_access_token = "" _access_token = ""
@@ -449,11 +409,6 @@ func _advance_account_generation() -> void:
_session_generation += 1 _session_generation += 1
_account_generation += 1 _account_generation += 1
_refresh_in_flight = false _refresh_in_flight = false
for request in _active_requests.duplicate():
if is_instance_valid(request) and bool(request.get_meta("account_bound", false)):
request.cancel_request()
request.queue_free()
_active_requests.erase(request)
func _return_to_auth_scene() -> void: func _return_to_auth_scene() -> void:
var tree := get_tree() var tree := get_tree()
@@ -496,20 +451,3 @@ func _password_has_letter_and_number(password: String) -> bool:
if code >= 48 and code <= 57: if code >= 48 and code <= 57:
has_number = true has_number = true
return has_letter and has_number return has_letter and has_number
func _http_result_to_string(result: int) -> String:
match result:
HTTPRequest.RESULT_SUCCESS:
return "SUCCESS"
HTTPRequest.RESULT_TIMEOUT:
return "TIMEOUT"
HTTPRequest.RESULT_CANT_CONNECT:
return "CANT_CONNECT"
HTTPRequest.RESULT_CANT_RESOLVE:
return "CANT_RESOLVE"
HTTPRequest.RESULT_CONNECTION_ERROR:
return "CONNECTION_ERROR"
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
return "TLS_HANDSHAKE_ERROR"
_:
return "UNKNOWN_%d" % result

View File

@@ -16,11 +16,8 @@ signal cafe_companion_agent_registered(data: Dictionary)
signal cafe_companion_employment_resigned(data: Dictionary) signal cafe_companion_employment_resigned(data: Dictionary)
signal cafe_companion_models_ready(data: Dictionary) signal cafe_companion_models_ready(data: Dictionary)
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const REQUEST_TIMEOUT: float = 24.0 const REQUEST_TIMEOUT: float = 24.0
var _activeRequests: Array[HTTPRequest] = []
var _cachedProducts: Array = [] var _cachedProducts: Array = []
var _cachedServicePoints: Array = [] var _cachedServicePoints: Array = []
var _accountGeneration: int = -1 var _accountGeneration: int = -1
@@ -31,9 +28,6 @@ func _ready() -> void:
if authManager != null and authManager.has_signal("auth_state_changed"): if authManager != null and authManager.has_signal("auth_state_changed"):
authManager.auth_state_changed.connect(_on_auth_state_changed) authManager.auth_state_changed.connect(_on_auth_state_changed)
func _exit_tree() -> void:
_cancel_active_requests()
func get_chat_time_products(forceRefresh: bool = false) -> void: func get_chat_time_products(forceRefresh: bool = false) -> void:
if not forceRefresh and not _cachedProducts.is_empty(): if not forceRefresh and not _cachedProducts.is_empty():
var cachedData := {"products": _cachedProducts} var cachedData := {"products": _cachedProducts}
@@ -114,56 +108,15 @@ func get_employment_models(protocol: String, baseUrl: String, token: String) ->
}, _on_employment_models_response) }, _on_employment_models_response)
func _request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_POST) -> void: func _request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_POST) -> void:
var request := HTTPRequest.new() var request_generation := _current_account_generation()
request.timeout = REQUEST_TIMEOUT ApiClient.request_json(endpoint, payload, func(success: bool, response: Dictionary, error_info: Dictionary) -> void:
add_child(request) if request_generation != _current_account_generation():
_activeRequests.append(request)
var requestGeneration := _current_account_generation()
request.request_completed.connect(func(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
_activeRequests.erase(request)
request.queue_free()
if requestGeneration != _current_account_generation():
return return
_handle_json_response(result, responseCode, body, callback) if not success:
) _emit_chat_error(str(error_info.get("message", "咖啡店陪聊请求失败")))
return
var headers := PackedStringArray(["Content-Type: application/json"]) callback.call(response)
var token := _get_auth_token() , method, true, REQUEST_TIMEOUT)
if not token.is_empty():
headers.append("Authorization: Bearer %s" % token)
var body := "" if method == HTTPClient.METHOD_GET else JSON.stringify(payload)
var err := request.request("%s%s" % [NetworkConfig.get_api_base_url(), endpoint], headers, method, body)
if err != OK:
_activeRequests.erase(request)
request.queue_free()
if requestGeneration == _current_account_generation():
_emit_chat_error("咖啡店陪聊请求发送失败: %s" % error_string(err))
func _handle_json_response(result: int, responseCode: int, body: PackedByteArray, callback: Callable) -> void:
if result != HTTPRequest.RESULT_SUCCESS:
_emit_chat_error("咖啡店陪聊网络请求失败: %s" % _http_result_to_string(result))
return
var bodyText := body.get_string_from_utf8()
var json := JSON.new()
if json.parse(bodyText) != OK:
_emit_chat_error("咖啡店陪聊响应解析失败")
return
var payloadVariant: Variant = json.data
if not (payloadVariant is Dictionary):
_emit_chat_error("咖啡店陪聊响应格式错误")
return
var response: Dictionary = payloadVariant
var success := responseCode >= 200 and responseCode < 300 and bool(response.get("success", true))
if not success:
_emit_chat_error(str(response.get("message", "咖啡店陪聊请求失败")))
return
callback.call(response)
func _on_chat_time_products_response(response: Dictionary) -> void: func _on_chat_time_products_response(response: Dictionary) -> void:
var data := _response_data(response) var data := _response_data(response)
@@ -257,30 +210,5 @@ func _on_auth_state_changed(_isAuthenticated: bool, _user: Dictionary) -> void:
if currentGeneration == _accountGeneration: if currentGeneration == _accountGeneration:
return return
_accountGeneration = currentGeneration _accountGeneration = currentGeneration
_cancel_active_requests()
_cachedProducts.clear() _cachedProducts.clear()
_cachedServicePoints.clear() _cachedServicePoints.clear()
func _cancel_active_requests() -> void:
for request in _activeRequests.duplicate():
if is_instance_valid(request):
request.cancel_request()
request.queue_free()
_activeRequests.clear()
func _http_result_to_string(result: int) -> String:
match result:
HTTPRequest.RESULT_SUCCESS:
return "SUCCESS"
HTTPRequest.RESULT_TIMEOUT:
return "TIMEOUT"
HTTPRequest.RESULT_CANT_CONNECT:
return "CANT_CONNECT"
HTTPRequest.RESULT_CANT_RESOLVE:
return "CANT_RESOLVE"
HTTPRequest.RESULT_CONNECTION_ERROR:
return "CONNECTION_ERROR"
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
return "TLS_HANDSHAKE_ERROR"
_:
return "HTTP_RESULT_%d" % result

View File

@@ -63,10 +63,7 @@ signal chat_position_updated(stream: String, topic: String)
# ============================================================================ # ============================================================================
const CHAT_WEBSOCKET_MANAGER_SCRIPT: Script = preload("res://_Core/managers/WebSocketManager.gd") const CHAT_WEBSOCKET_MANAGER_SCRIPT: Script = preload("res://_Core/managers/WebSocketManager.gd")
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd") const ChatMessageCodec = preload("res://_Core/chat/ChatMessageCodec.gd")
# WebSocket 服务器 URL原生 WebSocket
const WEBSOCKET_URL: String = "wss://whaletownend.xinghangee.icu/game"
# 重连配置 # 重连配置
const RECONNECT_MAX_ATTEMPTS: int = 5 const RECONNECT_MAX_ATTEMPTS: int = 5
@@ -107,7 +104,6 @@ const CHAT_ERROR_MESSAGES: Dictionary = {
# WebSocket 管理器 # WebSocket 管理器
var _websocket_manager: Node var _websocket_manager: Node
var _history_request: HTTPRequest
# 是否已登录 # 是否已登录
var _is_logged_in: bool = false var _is_logged_in: bool = false
@@ -119,6 +115,7 @@ var _message_history: Array[Dictionary] = []
var _history_loading: bool = false var _history_loading: bool = false
var _has_more_history: bool = true var _has_more_history: bool = true
var _oldest_message_timestamp: float = 0.0 var _oldest_message_timestamp: float = 0.0
var _history_request_generation: int = 0
# 消息发送时间戳(用于频率限制) # 消息发送时间戳(用于频率限制)
var _message_timestamps: Array[float] = [] var _message_timestamps: Array[float] = []
@@ -160,10 +157,6 @@ func _ready() -> void:
# 创建 WebSocket 管理器 # 创建 WebSocket 管理器
_websocket_manager = CHAT_WEBSOCKET_MANAGER_SCRIPT.new() _websocket_manager = CHAT_WEBSOCKET_MANAGER_SCRIPT.new()
add_child(_websocket_manager) add_child(_websocket_manager)
_history_request = HTTPRequest.new()
_history_request.timeout = 12.0
_history_request.request_completed.connect(_on_history_request_completed)
add_child(_history_request)
# 连接信号 # 连接信号
_connect_signals() _connect_signals()
@@ -661,6 +654,7 @@ func clear_message_history() -> void:
# - 用户登录成功后 # - 用户登录成功后
# - 重新连接到聊天服务器后 # - 重新连接到聊天服务器后
func reset_session() -> void: func reset_session() -> void:
_history_request_generation += 1
_message_history.clear() _message_history.clear()
_history_loading = false _history_loading = false
_has_more_history = true _has_more_history = true
@@ -695,59 +689,31 @@ func load_history(count: int = HISTORY_PAGE_SIZE) -> void:
var mapId := _current_map.strip_edges() var mapId := _current_map.strip_edges()
if mapId.is_empty(): if mapId.is_empty():
mapId = "whale_port" mapId = "whale_port"
var url := "%s/chat/history?mapId=%s&limit=%d&offset=%d" % [ var endpoint := "/chat/history?mapId=%s&limit=%d&offset=%d" % [
NetworkConfig.get_api_base_url(),
mapId.uri_encode(), mapId.uri_encode(),
max(1, count), max(1, count),
_message_history.size(), _message_history.size(),
] ]
var err := _history_request.request(url, PackedStringArray([ var request_generation := _history_request_generation
"Accept: application/json", ApiClient.get_json(endpoint, _on_history_request_completed.bind(request_generation))
"Authorization: Bearer %s" % token,
]), HTTPClient.METHOD_GET)
if err != OK:
_history_loading = false
_handle_error("INTERNAL_ERROR", "聊天历史请求发送失败")
func _on_history_request_completed(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void: func _on_history_request_completed(success: bool, response: Dictionary, error_info: Dictionary, request_generation: int) -> void:
if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300: if request_generation != _history_request_generation:
_history_loading = false
_handle_error("INTERNAL_ERROR", "聊天历史读取失败")
return return
var json := JSON.new() if not success:
if json.parse(body.get_string_from_utf8()) != OK or not (json.data is Dictionary):
_history_loading = false _history_loading = false
_handle_error("INTERNAL_ERROR", "聊天历史响应解析失败") _handle_error("INTERNAL_ERROR", str(error_info.get("message", "聊天历史读取失败")))
return return
var response: Dictionary = json.data
var messagesVariant: Variant = response.get("messages", []) var messagesVariant: Variant = response.get("messages", [])
var dataVariant: Variant = response.get("data", {}) var dataVariant: Variant = response.get("data", {})
if messagesVariant is Array and (messagesVariant as Array).is_empty() and dataVariant is Dictionary: if messagesVariant is Array and (messagesVariant as Array).is_empty() and dataVariant is Dictionary:
messagesVariant = (dataVariant as Dictionary).get("messages", []) messagesVariant = (dataVariant as Dictionary).get("messages", [])
if messagesVariant is Array: if messagesVariant is Array:
_on_history_loaded(_normalize_history_messages(messagesVariant as Array)) _on_history_loaded(ChatMessageCodec.normalize_history(messagesVariant as Array, _current_username))
return return
_history_loading = false _history_loading = false
_has_more_history = false _has_more_history = false
func _normalize_history_messages(messages: Array) -> Array:
var normalized: Array = []
for messageVariant in messages:
if not (messageVariant is Dictionary):
continue
var message: Dictionary = messageVariant
normalized.append({
"from_user": str(message.get("sender", message.get("from_user", ""))),
"from_user_id": str(message.get("fromUserId", message.get("from_user_id", ""))),
"content": str(message.get("content", "")),
"timestamp": _parse_chat_timestamp_to_unix(message.get("timestamp", 0.0)),
"is_self": str(message.get("sender", "")) == _current_username,
"scope": str(message.get("scope", "local")),
"show_bubble": bool(message.get("bubble", false)),
"is_history": true,
})
return normalized
# 历史消息加载完成回调 # 历史消息加载完成回调
func _on_history_loaded(messages: Array) -> void: func _on_history_loaded(messages: Array) -> void:
_history_loading = false _history_loading = false
@@ -979,6 +945,12 @@ func _on_data_received(message: String) -> void:
_handle_chat_error(data) _handle_chat_error(data)
"chat_render": "chat_render":
_handle_chat_render(data) _handle_chat_render(data)
"dm_message":
_handle_direct_message(data)
"dm_read":
_emit_event(EventNames.SOCIAL_NOTIFICATION_RECEIVED, {"type": "dm_read", "data": data})
"notification_created", "friendship_changed", "friend_presence_changed":
_emit_event(EventNames.SOCIAL_NOTIFICATION_RECEIVED, {"type": message_type, "data": data})
"friend_list": "friend_list":
_handle_friend_list(data) _handle_friend_list(data)
"friend_added": "friend_added":
@@ -1055,7 +1027,7 @@ func _handle_system_presence(data: Dictionary) -> void:
var content := str(data.get("content", "")).strip_edges() var content := str(data.get("content", "")).strip_edges()
if content.is_empty(): if content.is_empty():
return return
var timestamp := _parse_chat_timestamp_to_unix(data.get("timestamp", 0.0)) var timestamp := ChatMessageCodec.parse_timestamp(data.get("timestamp", 0.0))
var message := { var message := {
"from_user": "系统", "from_user": "系统",
"from_user_id": "", "from_user_id": "",
@@ -1147,7 +1119,8 @@ func _handle_friend_list(data: Dictionary) -> void:
_friends.append({ _friends.append({
"user_id": str(friend.get("userId", friend.get("user_id", ""))).strip_edges(), "user_id": str(friend.get("userId", friend.get("user_id", ""))).strip_edges(),
"username": str(friend.get("username", "")), "username": str(friend.get("username", "")),
"online": bool(friend.get("online", false)) "online": bool(friend.get("online", false)),
"room_visitable": bool(friend.get("room_visitable", false))
}) })
_emit_event(EventNames.CHAT_FRIENDS_UPDATED, { _emit_event(EventNames.CHAT_FRIENDS_UPDATED, {
@@ -1166,7 +1139,8 @@ func _handle_friend_added(data: Dictionary) -> void:
_upsert_friend({ _upsert_friend({
"user_id": friend_user_id, "user_id": friend_user_id,
"username": str(friend.get("username", "")), "username": str(friend.get("username", "")),
"online": bool(friend.get("online", false)) "online": bool(friend.get("online", false)),
"room_visitable": bool(friend.get("room_visitable", false))
}) })
request_friend_list() request_friend_list()
@@ -1257,7 +1231,7 @@ func _handle_chat_render(data: Dictionary) -> void:
var private_context: String = str(data.get("privateContext", data.get("private_context", ""))).strip_edges() var private_context: String = str(data.get("privateContext", data.get("private_context", ""))).strip_edges()
var is_private: bool = scope == "private" var is_private: bool = scope == "private"
var timestamp: float = _parse_chat_timestamp_to_unix(data.get("timestamp", 0.0)) var timestamp: float = ChatMessageCodec.parse_timestamp(data.get("timestamp", 0.0))
var is_self: bool = (not _current_username.is_empty() and from_user == _current_username) var is_self: bool = (not _current_username.is_empty() and from_user == _current_username)
if is_self and _consume_pending_self_message(content, scope, to_user_id): if is_self and _consume_pending_self_message(content, scope, to_user_id):
@@ -1301,39 +1275,40 @@ func _handle_chat_render(data: Dictionary) -> void:
"is_private": is_private "is_private": is_private
}) })
# 解析聊天消息时间戳(兼容 unix 秒 / ISO 8601 字符串) func _handle_direct_message(data: Dictionary) -> void:
func _parse_chat_timestamp_to_unix(timestamp_raw: Variant) -> float: var message_variant: Variant = data.get("message", {})
if typeof(timestamp_raw) == TYPE_INT or typeof(timestamp_raw) == TYPE_FLOAT: if not (message_variant is Dictionary):
var ts := float(timestamp_raw) return
return ts if ts > 0.0 else Time.get_unix_time_from_system() var message: Dictionary = message_variant
var sender_variant: Variant = message.get("sender", {})
var ts_str := str(timestamp_raw) var sender: Dictionary = sender_variant if sender_variant is Dictionary else {}
if ts_str.strip_edges().is_empty(): var sender_id := str(message.get("senderId", "")).strip_edges()
return Time.get_unix_time_from_system() var recipient_id := str(message.get("recipientId", "")).strip_edges()
var from_user := str(sender.get("nickname", sender.get("username", "玩家"))).strip_edges()
# 纯数字字符串(必须整串都是数字/小数点,避免把 ISO 字符串前缀 "2026" 误判成时间戳) var is_self := sender_id == _current_user_id()
var numeric_regex := RegEx.new() var payload := {
numeric_regex.compile("^\\s*-?\\d+(?:\\.\\d+)?\\s*$") "from": from_user,
if numeric_regex.search(ts_str) != null: "fromUserId": sender_id,
var ts_num := float(ts_str) "txt": str(message.get("content", "")),
return ts_num if ts_num > 0.0 else Time.get_unix_time_from_system() "timestamp": message.get("createdAt", Time.get_unix_time_from_system()),
"scope": "private",
# ISO 8601: 2026-01-19T15:15:43.930Z "toUserId": recipient_id,
var regex := RegEx.new() "toUsername": "",
regex.compile("(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})") "privateContext": "dm",
var result := regex.search(ts_str) "bubble": false,
if result == null:
return Time.get_unix_time_from_system()
var utc_dict := {
"year": int(result.get_string(1)),
"month": int(result.get_string(2)),
"day": int(result.get_string(3)),
"hour": int(result.get_string(4)),
"minute": int(result.get_string(5)),
"second": int(result.get_string(6))
} }
return Time.get_unix_time_from_datetime_dict(utc_dict) if is_self and _consume_pending_self_message(str(payload.get("txt", "")), "private", recipient_id):
return
_handle_chat_render(payload)
_emit_event(EventNames.SOCIAL_NOTIFICATION_RECEIVED, {"type": "dm_message", "data": data})
func _current_user_id() -> String:
var authManager := get_node_or_null("/root/AuthManager")
if authManager != null and authManager.has_method("get_current_user"):
var user_variant: Variant = authManager.call("get_current_user")
if user_variant is Dictionary:
return str((user_variant as Dictionary).get("id", ""))
return ""
# 处理位置更新成功 # 处理位置更新成功
func _handle_position_updated(data: Dictionary) -> void: func _handle_position_updated(data: Dictionary) -> void:
@@ -1511,12 +1486,14 @@ func _normalize_friend_requests(requests_variant: Variant) -> Array[Dictionary]:
if not (request_variant is Dictionary): if not (request_variant is Dictionary):
continue continue
var request: Dictionary = request_variant var request: Dictionary = request_variant
var user_id := str(request.get("userId", request.get("user_id", ""))).strip_edges() var requester_variant: Variant = request.get("requester", {})
var requester: Dictionary = requester_variant if requester_variant is Dictionary else {}
var user_id := str(request.get("userId", request.get("user_id", requester.get("id", "")))).strip_edges()
if user_id.is_empty(): if user_id.is_empty():
continue continue
requests.append({ requests.append({
"user_id": user_id, "user_id": user_id,
"username": str(request.get("username", "玩家")).strip_edges(), "username": str(request.get("username", requester.get("nickname", requester.get("username", "玩家")))).strip_edges(),
"created_at": str(request.get("createdAt", request.get("created_at", ""))).strip_edges() "created_at": str(request.get("createdAt", request.get("created_at", ""))).strip_edges()
}) })
return requests return requests

View File

@@ -0,0 +1,245 @@
extends Node
# 全地图统一交互收集角色附近的动作通过方向键选择、E 执行。
const SCAN_INTERVAL: float = 0.10
const MAX_ACTIONS_VISIBLE: int = 5
const ACCENT: Color = Color("58c7db")
const PANEL_COLOR: Color = Color(0.035, 0.071, 0.106, 0.94)
const INTERACTION_POINT_MARKERS_SCRIPT: Script = preload("res://_Core/ui/InteractionPointMarkers.gd")
var _actions: Array[InteractionAction] = []
var _selected_index: int = 0
var _last_selected_id: String = ""
var _scan_elapsed: float = SCAN_INTERVAL
var _canvas: CanvasLayer
var _panel: PanelContainer
var _list: VBoxContainer
var _hint: Label
var _executing: bool = false
var _pointMarkers: Node2D
func _ready() -> void:
_build_hud()
func _process(delta: float) -> void:
_scan_elapsed += delta
if _scan_elapsed < SCAN_INTERVAL:
return
_scan_elapsed = 0.0
_refresh_actions()
func _unhandled_input(event: InputEvent) -> void:
if _actions.is_empty() or _is_text_input_focused():
return
if not (event is InputEventKey):
return
var key_event := event as InputEventKey
if not key_event.pressed or key_event.echo:
return
if key_event.keycode == KEY_UP:
_select_offset(-1)
get_viewport().set_input_as_handled()
elif key_event.keycode == KEY_DOWN:
_select_offset(1)
get_viewport().set_input_as_handled()
elif key_event.keycode == KEY_E:
_execute_selected()
get_viewport().set_input_as_handled()
func is_selection_active() -> bool:
return not _actions.is_empty() and not _is_text_input_focused()
func _refresh_actions() -> void:
if _is_text_input_focused() or SceneManager.is_changing_scene:
_set_actions([])
return
var player := _local_player()
if player == null:
_set_actions([])
return
var collected: Array[InteractionAction] = []
for node: Node in get_tree().get_nodes_in_group(InteractableComponent.GROUP):
var interactable: InteractableComponent = node as InteractableComponent
if interactable == null:
continue
for action: InteractionAction in interactable.get_actions(player):
collected.append(action)
collected.sort_custom(func(a: InteractionAction, b: InteractionAction) -> bool:
if a.priority != b.priority:
return a.priority < b.priority
if not is_equal_approx(a.distance, b.distance):
return a.distance < b.distance
return a.title < b.title
)
_set_actions(collected)
func _set_actions(next_actions: Array[InteractionAction]) -> void:
_actions = next_actions
if _actions.is_empty():
_selected_index = 0
_last_selected_id = ""
_render()
return
var matched_index := -1
for index in _actions.size():
if _actions[index].id == _last_selected_id:
matched_index = index
break
_selected_index = matched_index if matched_index >= 0 else clampi(_selected_index, 0, _actions.size() - 1)
_last_selected_id = _actions[_selected_index].id
_render()
func _select_offset(offset: int) -> void:
if _actions.is_empty():
return
_selected_index = posmod(_selected_index + offset, _actions.size())
_last_selected_id = _actions[_selected_index].id
_render()
func _execute_selected() -> void:
if _executing or _actions.is_empty():
return
var action: InteractionAction = _actions[_selected_index]
if not action.activate.is_valid():
return
_executing = true
_render()
action.activate.call()
await get_tree().create_timer(0.18).timeout
_executing = false
_refresh_actions()
func _local_player() -> Node2D:
var players := get_tree().get_nodes_in_group("whaletown_local_player")
return players.front() as Node2D if not players.is_empty() else null
func _build_hud() -> void:
_canvas = CanvasLayer.new()
_canvas.layer = 90
add_child(_canvas)
_panel = PanelContainer.new()
_panel.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
_panel.position = Vector2(-250, -282)
_panel.size = Vector2(500, 214)
_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_panel.add_theme_stylebox_override("panel", _panel_style())
_canvas.add_child(_panel)
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 14)
margin.add_theme_constant_override("margin_top", 10)
margin.add_theme_constant_override("margin_right", 14)
margin.add_theme_constant_override("margin_bottom", 10)
_panel.add_child(margin)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 4)
margin.add_child(content)
_list = VBoxContainer.new()
_list.add_theme_constant_override("separation", 2)
content.add_child(_list)
_hint = Label.new()
_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_hint.add_theme_color_override("font_color", Color(0.67, 0.79, 0.85, 1.0))
_hint.add_theme_font_size_override("font_size", 14)
_hint.text = "[↑/↓] 选择 [E] 交互"
content.add_child(_hint)
_render()
func _render() -> void:
if not is_instance_valid(_panel):
return
var show_hints: bool = _should_show_interaction_hints()
_panel.visible = show_hints and not _actions.is_empty()
_render_interaction_points()
for child in _list.get_children():
child.queue_free()
if _actions.is_empty():
return
var start: int = clampi(_selected_index - 2, 0, maxi(0, _actions.size() - MAX_ACTIONS_VISIBLE))
var finish: int = mini(_actions.size(), start + MAX_ACTIONS_VISIBLE)
for index in range(start, finish):
var action: InteractionAction = _actions[index]
var row := Label.new()
row.custom_minimum_size = Vector2(0, 29)
row.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
row.add_theme_font_size_override("font_size", 16)
var selected := index == _selected_index
row.text = (" " if selected else " ") + action.title + ("" if selected and _executing else "")
row.add_theme_color_override("font_color", ACCENT if selected else Color(0.92, 0.96, 0.98, 1.0))
if selected:
row.add_theme_stylebox_override("normal", _selected_style())
_list.add_child(row)
_hint.text = "[↑/↓] 选择 [E] 交互 [Esc] 关闭" + (" %d" % _actions.size() if _actions.size() > 1 else "")
func _render_interaction_points() -> void:
if not _should_show_interaction_points():
if is_instance_valid(_pointMarkers):
var empty_points: Array[Vector2] = []
_pointMarkers.call("set_points", empty_points)
return
var markers: Node2D = _ensure_point_markers()
if markers == null:
return
var positions: Array[Vector2] = []
for node: Node in get_tree().get_nodes_in_group(InteractableComponent.GROUP):
var interactable: InteractableComponent = node as InteractableComponent
if interactable == null:
continue
for position: Vector2 in interactable.get_marker_positions():
positions.append(position)
markers.call("set_points", positions)
func _ensure_point_markers() -> Node2D:
var current_scene: Node = get_tree().current_scene
var world_root: Node2D = current_scene as Node2D
if world_root == null:
return null
if is_instance_valid(_pointMarkers) and _pointMarkers.get_parent() == world_root:
return _pointMarkers
var markers: Node2D = INTERACTION_POINT_MARKERS_SCRIPT.new() as Node2D
if markers == null:
return null
markers.name = "InteractionPointMarkers"
markers.z_index = 4096
markers.z_as_relative = false
world_root.add_child(markers)
_pointMarkers = markers
return _pointMarkers
func _should_show_interaction_hints() -> bool:
return _get_setting_enabled("show_interaction_hints", true)
func _should_show_interaction_points() -> bool:
return _get_setting_enabled("show_interaction_points", false)
func _get_setting_enabled(setting_key: String, fallback: bool) -> bool:
var settings_manager: Node = get_node_or_null("/root/SettingsManager")
if settings_manager != null and settings_manager.has_method("get_bool"):
return bool(settings_manager.call("get_bool", setting_key))
return fallback
func _panel_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = PANEL_COLOR
style.border_color = Color(0.20, 0.45, 0.54, 0.86)
style.set_border_width_all(1)
style.corner_radius_top_left = 10
style.corner_radius_top_right = 10
style.corner_radius_bottom_left = 10
style.corner_radius_bottom_right = 10
style.shadow_color = Color(0, 0, 0, 0.35)
style.shadow_size = 10
return style
func _selected_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.12, 0.32, 0.38, 0.74)
style.corner_radius_top_left = 6
style.corner_radius_top_right = 6
style.corner_radius_bottom_left = 6
style.corner_radius_bottom_right = 6
style.content_margin_left = 6
return style
func _is_text_input_focused() -> bool:
var focus_owner := get_viewport().gui_get_focus_owner()
return focus_owner is LineEdit or focus_owner is TextEdit

View File

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

View File

@@ -2,182 +2,188 @@ extends Node
signal decor_save_succeeded(item: Dictionary) signal decor_save_succeeded(item: Dictionary)
signal decor_save_failed(item: Dictionary, message: String) signal decor_save_failed(item: Dictionary, message: String)
signal decor_revision_conflict(current_revision: int, message: String)
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const REQUEST_TIMEOUT: float = 12.0
const MAX_SAVE_RETRIES: int = 3 const MAX_SAVE_RETRIES: int = 3
const RETRY_BASE_DELAY: float = 0.75 const RETRY_BASE_DELAY: float = 0.75
var _request: HTTPRequest var _retry_timer: Timer
var _retryTimer: Timer
var _queue: Array[Dictionary] = [] var _queue: Array[Dictionary] = []
var _inFlight: Dictionary = {} var _in_flight: Dictionary = {}
var _requestGeneration: int = -1 var _account_generation: int = -1
var _accountGeneration: int = -1 var _request_serial: int = 0
var _confirmed_revision: int = 0
func _ready() -> void: func _ready() -> void:
_request = HTTPRequest.new() _retry_timer = Timer.new()
_request.name = "roomDecorSaveRequest" _retry_timer.name = "roomDecorSaveRetryTimer"
_request.timeout = REQUEST_TIMEOUT _retry_timer.one_shot = true
_request.request_completed.connect(_on_request_completed) _retry_timer.timeout.connect(_process_next_save)
add_child(_request) add_child(_retry_timer)
_retryTimer = Timer.new() var auth_manager := get_node_or_null("/root/AuthManager")
_retryTimer.name = "roomDecorSaveRetryTimer" if auth_manager != null and auth_manager.has_signal("auth_state_changed"):
_retryTimer.one_shot = true _account_generation = _current_account_generation()
_retryTimer.timeout.connect(_process_next_save) auth_manager.auth_state_changed.connect(_on_auth_state_changed)
add_child(_retryTimer)
var authManager := get_node_or_null("/root/AuthManager") func set_layout_revision(revision: int, clear_pending: bool = true) -> void:
if authManager != null and authManager.has_signal("auth_state_changed"): if clear_pending:
_accountGeneration = _current_account_generation() clear_pending_saves()
authManager.auth_state_changed.connect(_on_auth_state_changed) _confirmed_revision = maxi(0, revision)
func get_layout_revision() -> int:
return _confirmed_revision
func clear_pending_saves() -> void:
_request_serial += 1
if is_instance_valid(_retry_timer):
_retry_timer.stop()
_queue.clear()
_in_flight.clear()
func enqueue_save(item: Dictionary) -> bool: func enqueue_save(item: Dictionary) -> bool:
if not _is_authenticated(): if not _is_authenticated():
decor_save_failed.emit(item.duplicate(true), "请先登录后保存摆放") decor_save_failed.emit(item.duplicate(true), "请先登录后保存摆放")
return false return false
var decorId := str(item.get("decor_id", "")).strip_edges() var decor_id := str(item.get("decor_id", "")).strip_edges()
if decorId.is_empty(): if decor_id.is_empty():
decor_save_failed.emit(item.duplicate(true), "家具数据缺少 decor_id") decor_save_failed.emit(item.duplicate(true), "家具数据缺少 decor_id")
return false return false
# Only the newest queued placement for a decor matters.
for index in range(_queue.size() - 1, -1, -1): for index in range(_queue.size() - 1, -1, -1):
var queuedItem: Dictionary = _queue[index].get("item", {}) var queued_item: Dictionary = _queue[index].get("item", {})
if str(queuedItem.get("decor_id", "")) == decorId: if str(queued_item.get("decor_id", "")) == decor_id:
_queue.remove_at(index) _queue.remove_at(index)
_queue.append({"item": item.duplicate(true), "retry_count": 0}) _queue.append({
"item": item.duplicate(true),
"retry_count": 0,
"mutation_id": _create_mutation_id(),
})
_process_next_save() _process_next_save()
return true return true
func has_pending_saves() -> bool: func has_pending_saves() -> bool:
return not _inFlight.is_empty() or not _queue.is_empty() or (_retryTimer != null and not _retryTimer.is_stopped()) return not _in_flight.is_empty() or not _queue.is_empty() or (_retry_timer != null and not _retry_timer.is_stopped())
func _process_next_save() -> void: func _process_next_save() -> void:
if not _inFlight.is_empty() or _queue.is_empty() or not _is_authenticated(): if not _in_flight.is_empty() or _queue.is_empty() or not _is_authenticated():
return return
if _retryTimer != null and not _retryTimer.is_stopped(): if _retry_timer != null and not _retry_timer.is_stopped():
return return
_inFlight = _queue.pop_front()
_requestGeneration = _current_account_generation() _in_flight = _queue.pop_front()
var item: Dictionary = _inFlight.get("item", {}) if not _in_flight.has("layout_revision"):
_in_flight["layout_revision"] = _confirmed_revision
_in_flight["mutation_revision"] = _confirmed_revision + 1
var item: Dictionary = _in_flight.get("item", {})
var payload := { var payload := {
"decor_id": str(item.get("decor_id", "")), "decor_id": str(item.get("decor_id", "")),
"placed": bool(item.get("placed", false)), "placed": bool(item.get("placed", false)),
"position_x": float(item.get("position_x", 0.0)), "position_x": float(item.get("position_x", 0.0)),
"position_y": float(item.get("position_y", 0.0)), "position_y": float(item.get("position_y", 0.0)),
"scale": float(item.get("scale", item.get("default_scale", 1.0))), "scale": float(item.get("scale", item.get("default_scale", 1.0))),
"rotation_degrees": int(item.get("rotation_degrees", item.get("default_rotation_degrees", 0))),
"z_index": int(item.get("z_index", item.get("default_z_index", 0))), "z_index": int(item.get("z_index", item.get("default_z_index", 0))),
"layout_revision": int(_in_flight.get("layout_revision", _confirmed_revision)),
"mutation_revision": int(_in_flight.get("mutation_revision", _confirmed_revision + 1)),
"mutation_id": str(_in_flight.get("mutation_id", "")),
} }
var decorId := str(item.get("decor_id", "")).uri_encode() var api_client := get_node_or_null("/root/ApiClient")
var err := _request.request( if api_client == null or not api_client.has_method("put_json"):
"%s/rooms/me/decor-placements/%s" % [NetworkConfig.get_api_base_url(), decorId], _retry_or_fail("家具位置保存服务不可用")
_auth_headers(), return
HTTPClient.METHOD_PUT, _request_serial += 1
JSON.stringify(payload) var serial := _request_serial
var generation := _current_account_generation()
var decor_id := str(item.get("decor_id", "")).uri_encode()
api_client.call(
"put_json",
"/rooms/me/decor-placements/%s" % decor_id,
payload,
Callable(self, "_on_save_response").bind(serial, generation),
true
) )
if err != OK:
_retry_or_fail("家具位置保存请求发送失败")
func _on_request_completed(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void: func _on_save_response(
if _inFlight.is_empty(): success: bool,
response: Dictionary,
error_info: Dictionary,
serial: int,
generation: int
) -> void:
if serial != _request_serial or _in_flight.is_empty():
return return
if _requestGeneration != _current_account_generation(): if generation != _current_account_generation():
_inFlight.clear() clear_pending_saves()
_requestGeneration = -1
_process_next_save()
return return
if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300: if not success:
_retry_or_fail(_read_error_message(body, "家具位置保存失败")) var message := str(error_info.get("message", "家具位置保存失败"))
return if int(error_info.get("response_code", 0)) == 409:
_handle_revision_conflict(error_info, message)
var savedItem: Dictionary = (_inFlight.get("item", {}) as Dictionary).duplicate(true)
var json := JSON.new()
if json.parse(body.get_string_from_utf8()) == OK and json.data is Dictionary:
var response: Dictionary = json.data
if not bool(response.get("success", true)):
_retry_or_fail(str(response.get("message", "家具位置保存失败")))
return return
var dataVariant: Variant = response.get("data", {}) _retry_or_fail(message)
if dataVariant is Dictionary: return
savedItem = (dataVariant as Dictionary).duplicate(true)
_inFlight.clear() var saved_item: Dictionary = (_in_flight.get("item", {}) as Dictionary).duplicate(true)
_requestGeneration = -1 var data_variant: Variant = response.get("data", {})
decor_save_succeeded.emit(savedItem) if data_variant is Dictionary:
saved_item = (data_variant as Dictionary).duplicate(true)
var accepted_revision := int(saved_item.get("layout_revision", _in_flight.get("mutation_revision", _confirmed_revision)))
_confirmed_revision = maxi(_confirmed_revision, accepted_revision)
_in_flight.clear()
decor_save_succeeded.emit(saved_item)
_process_next_save() _process_next_save()
func _retry_or_fail(message: String) -> void: func _retry_or_fail(message: String) -> void:
if _inFlight.is_empty(): if _in_flight.is_empty():
return return
var failedEntry := _inFlight.duplicate(true) var failed_entry := _in_flight.duplicate(true)
var failedItem: Dictionary = failedEntry.get("item", {}) var failed_item: Dictionary = failed_entry.get("item", {})
var decorId := str(failedItem.get("decor_id", "")) _in_flight.clear()
_inFlight.clear()
_requestGeneration = -1
if _queue_has_decor(decorId): var retry_count := int(failed_entry.get("retry_count", 0)) + 1
_process_next_save() if retry_count <= MAX_SAVE_RETRIES and _is_authenticated():
return failed_entry["retry_count"] = retry_count
var retryCount := int(failedEntry.get("retry_count", 0)) + 1 _queue.push_front(failed_entry)
if retryCount <= MAX_SAVE_RETRIES and _is_authenticated(): _retry_timer.start(RETRY_BASE_DELAY * pow(2.0, retry_count - 1))
failedEntry["retry_count"] = retryCount
_queue.push_front(failedEntry)
_retryTimer.start(RETRY_BASE_DELAY * pow(2.0, retryCount - 1))
return return
decor_save_failed.emit(failedItem.duplicate(true), message) decor_save_failed.emit(failed_item.duplicate(true), message)
_process_next_save() _fail_queued_entries("前序装修写入失败,请重新保存")
func _queue_has_decor(decorId: String) -> bool: func _handle_revision_conflict(error_info: Dictionary, message: String) -> void:
for entryVariant in _queue: var current_revision := int(error_info.get("current_revision", _confirmed_revision))
if entryVariant is Dictionary: var failed_item: Dictionary = (_in_flight.get("item", {}) as Dictionary).duplicate(true)
var item: Dictionary = (entryVariant as Dictionary).get("item", {}) _in_flight.clear()
if str(item.get("decor_id", "")) == decorId: decor_save_failed.emit(failed_item, message)
return true _fail_queued_entries(message)
return false _confirmed_revision = maxi(_confirmed_revision, current_revision)
decor_revision_conflict.emit(current_revision, message)
func _on_auth_state_changed(_isAuthenticated: bool, _user: Dictionary) -> void: func _fail_queued_entries(message: String) -> void:
var currentGeneration := _current_account_generation() for entry in _queue:
if currentGeneration == _accountGeneration: var item_variant: Variant = entry.get("item", {})
return if item_variant is Dictionary:
_accountGeneration = currentGeneration decor_save_failed.emit((item_variant as Dictionary).duplicate(true), message)
if is_instance_valid(_request):
_request.cancel_request()
if is_instance_valid(_retryTimer):
_retryTimer.stop()
_queue.clear() _queue.clear()
_inFlight.clear() if is_instance_valid(_retry_timer):
_requestGeneration = -1 _retry_timer.stop()
func _auth_headers() -> PackedStringArray: func _on_auth_state_changed(_is_authenticated_value: bool, _user: Dictionary) -> void:
var authManager := get_node_or_null("/root/AuthManager") var current_generation := _current_account_generation()
var accessToken := str(authManager.call("get_access_token")).strip_edges() if authManager != null and authManager.has_method("get_access_token") else "" if current_generation == _account_generation:
return PackedStringArray([ return
"Content-Type: application/json", _account_generation = current_generation
"Authorization: Bearer %s" % accessToken, clear_pending_saves()
]) _confirmed_revision = 0
func _is_authenticated() -> bool: func _is_authenticated() -> bool:
var authManager := get_node_or_null("/root/AuthManager") var auth_manager := get_node_or_null("/root/AuthManager")
return authManager != null and authManager.has_method("is_authenticated") and bool(authManager.call("is_authenticated")) return auth_manager != null and auth_manager.has_method("is_authenticated") and bool(auth_manager.call("is_authenticated"))
func _current_account_generation() -> int: func _current_account_generation() -> int:
var authManager := get_node_or_null("/root/AuthManager") var auth_manager := get_node_or_null("/root/AuthManager")
return int(authManager.call("get_account_generation")) if authManager != null and authManager.has_method("get_account_generation") else -1 return int(auth_manager.call("get_account_generation")) if auth_manager != null and auth_manager.has_method("get_account_generation") else -1
func _read_error_message(body: PackedByteArray, fallback: String) -> String: func _create_mutation_id() -> String:
var json := JSON.new() return Crypto.new().generate_random_bytes(16).hex_encode()
if json.parse(body.get_string_from_utf8()) != OK or not (json.data is Dictionary):
return fallback
var response: Dictionary = json.data
var messageVariant: Variant = response.get("message", fallback)
if messageVariant is Array:
var parts: Array[String] = []
for part in messageVariant:
parts.append(str(part))
return "; ".join(parts) if not parts.is_empty() else fallback
var message := str(messageVariant).strip_edges()
return message if not message.is_empty() else fallback

View File

@@ -38,6 +38,8 @@ 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) var _next_spawn_name: String = "" # 下一个场景的出生点名称 (String)
var _next_destination_id: String = "" # 快速传送目标,用于发现登记
var _room_visit_context: Dictionary = {} # 访客房间的房主与返回位置
# 场景路径映射表 # 场景路径映射表
# 将场景名称映射到实际的文件路径 # 将场景名称映射到实际的文件路径
@@ -194,6 +196,47 @@ func get_next_spawn_name() -> String:
_next_spawn_name = "" _next_spawn_name = ""
return spawn_name return spawn_name
func set_next_destination_id(destination_id: String) -> void:
_next_destination_id = destination_id.strip_edges()
func get_next_destination_id() -> String:
var destination_id := _next_destination_id
_next_destination_id = ""
return destination_id
# ============ 访客房间上下文 ============
func begin_room_visit(owner_user_id: String, owner_nickname: String, return_scene: String, return_position: Vector2) -> void:
_room_visit_context = {
"owner_user_id": owner_user_id.strip_edges(),
"owner_nickname": owner_nickname.strip_edges(),
"return_scene": return_scene.strip_edges(),
"return_position": return_position,
}
func get_room_visit_context() -> Dictionary:
return _room_visit_context.duplicate(true)
func has_room_visit_context() -> bool:
return not str(_room_visit_context.get("owner_user_id", "")).strip_edges().is_empty()
func clear_room_visit_context() -> void:
_room_visit_context.clear()
func return_from_room_visit() -> bool:
if not has_room_visit_context():
return false
var context: Dictionary = _room_visit_context.duplicate(true)
clear_room_visit_context()
var return_scene: String = str(context.get("return_scene", "square")).strip_edges()
if not scene_paths.has(return_scene):
return_scene = "square"
var return_position: Variant = context.get("return_position", null)
if return_position is Vector2:
set_next_scene_position(return_position as Vector2)
change_scene(return_scene)
return true
# ============ 场景注册方法 ============ # ============ 场景注册方法 ============
# 注册新场景 # 注册新场景

View File

@@ -15,7 +15,8 @@ const DEFAULT_SETTINGS: Dictionary = {
"effects_volume": 0.90, "effects_volume": 0.90,
"ui_scale": 1.00, "ui_scale": 1.00,
"fullscreen": false, "fullscreen": false,
"show_interaction_hints": false, "show_interaction_hints": true,
"show_interaction_points": false,
"show_name_always": false, "show_name_always": false,
"show_chat_bubbles": true, "show_chat_bubbles": true,
"world_notifications": true, "world_notifications": true,
@@ -23,6 +24,8 @@ const DEFAULT_SETTINGS: Dictionary = {
"friend_request_notifications": true, "friend_request_notifications": true,
"allow_nearby_private": true, "allow_nearby_private": true,
"allow_nearby_friend_requests": true, "allow_nearby_friend_requests": true,
"allow_nearby_profile": true,
"room_visit_policy": "friends",
"mute_ui_sfx": false, "mute_ui_sfx": false,
} }

View File

@@ -0,0 +1,480 @@
extends Node
# 社区资料、通知与旅行 API 的客户端协调器。
const ACCENT := Color("58c7db")
const PANEL_COLOR := Color(0.035, 0.071, 0.106, 0.97)
const DISCOVERY_DISTANCE: float = 180.0
const DISCOVERY_POINTS: Array[Dictionary] = [
{"id": "square_center", "mapId": "whale_port", "position": Vector2(0, 30)},
{"id": "square_dock", "mapId": "whale_port", "position": Vector2(-870, -202)},
{"id": "square_headquarters", "mapId": "whale_port", "position": Vector2(13, -600)},
{"id": "square_cottage", "mapId": "whale_port", "position": Vector2(845, -192)},
{"id": "square_workshop", "mapId": "whale_port", "position": Vector2(645, 500)},
{"id": "square_notice", "mapId": "whale_port", "position": Vector2(-542, 648)},
{"id": "square_work_zone_gate", "mapId": "whale_port", "position": Vector2(0, 775)},
{"id": "work_entrance", "mapId": "work_zone", "position": Vector2(0, 755)},
{"id": "work_mall", "mapId": "work_zone", "position": Vector2(0, -614)},
{"id": "work_cafe_gate", "mapId": "work_zone", "position": Vector2(-1044, 222)},
{"id": "work_jobs", "mapId": "work_zone", "position": Vector2(-502, 192)},
{"id": "work_courses", "mapId": "work_zone", "position": Vector2(496, 0)},
{"id": "work_ai", "mapId": "work_zone", "position": Vector2(452, 548)},
{"id": "work_exchange", "mapId": "work_zone", "position": Vector2(1075, 548)},
{"id": "cafe_entrance", "mapId": "whale_cafe", "position": Vector2(0, 363)},
{"id": "cafe_counter", "mapId": "whale_cafe", "position": Vector2(0, -37)},
{"id": "cafe_companion", "mapId": "whale_cafe", "position": Vector2(-398, -226)},
{"id": "personal_room", "mapId": "personal_space", "position": Vector2.ZERO},
]
var _canvas: CanvasLayer
var _profile_panel: PanelContainer
var _profile_content: VBoxContainer
var _notification_panel: PanelContainer
var _notification_content: VBoxContainer
var _current_profile: Dictionary = {}
var _discoveryRequests: Dictionary = {}
var _discoveredDestinations: Dictionary = {}
func _ready() -> void:
add_to_group("whaletown_escape_dismissible")
_build_ui()
var event_system := get_node_or_null("/root/EventSystem")
if event_system != null:
event_system.call("connect_event", EventNames.SOCIAL_NOTIFICATION_RECEIVED, _on_social_realtime, self)
func _exit_tree() -> void:
var event_system := get_node_or_null("/root/EventSystem")
if event_system != null:
event_system.call("disconnect_event", EventNames.SOCIAL_NOTIFICATION_RECEIVED, _on_social_realtime, self)
func show_profile(user_id: String) -> void:
var normalized := user_id.strip_edges()
if normalized.is_empty():
return
_request_get("/social/profiles/%s" % normalized, func(success: bool, response: Dictionary, error: Dictionary) -> void:
if not success:
_show_status("无法查看名片:%s" % str(error.get("message", "请求失败")))
return
var data_variant: Variant = response.get("data", {})
if data_variant is Dictionary:
_current_profile = data_variant as Dictionary
_render_profile()
)
func show_own_profile() -> void:
_request_get("/social/profile", func(success: bool, response: Dictionary, error: Dictionary) -> void:
if not success:
_show_status("无法读取个人名片:%s" % str(error.get("message", "请求失败")))
return
var data_variant: Variant = response.get("data", {})
if data_variant is Dictionary:
_current_profile = data_variant as Dictionary
_render_profile()
)
func request_friend(user_id: String, username: String = "") -> void:
var chat := get_node_or_null("/root/ChatManager")
if chat != null and chat.has_method("request_friend"):
chat.call("request_friend", user_id, username)
func open_private_chat(user_id: String, username: String) -> void:
var event_system := get_node_or_null("/root/EventSystem")
if event_system != null:
event_system.call("emit_event", EventNames.CHAT_PRIVATE_TARGET_SELECTED, {"userId": user_id, "username": username})
_close_profile()
func visit_room(user_id: String, nickname: String = "") -> void:
var owner_id: String = user_id.strip_edges()
if owner_id.is_empty():
return
_request_get("/social/profiles/%s" % owner_id.uri_encode(), func(success: bool, response: Dictionary, error: Dictionary) -> void:
if not success:
_show_status("无法访问房间:%s" % str(error.get("message", "请求失败")))
return
var profile_variant: Variant = response.get("data", {})
if not (profile_variant is Dictionary) or not bool((profile_variant as Dictionary).get("room_visitable", false)):
_show_status("该玩家当前不开放个人空间访问")
return
var profile: Dictionary = profile_variant as Dictionary
_begin_room_visit(owner_id, str(profile.get("nickname", nickname)))
)
func _begin_room_visit(owner_id: String, nickname: String) -> void:
var current_scene: Node = get_tree().current_scene
var return_scene: String = SceneManager.get_current_scene_name().strip_edges()
if return_scene.is_empty() and current_scene != null:
return_scene = _scene_name_to_id(current_scene.name)
if return_scene.is_empty() or return_scene == "personal_space":
return_scene = "square"
var return_position := Vector2.ZERO
if current_scene != null:
var player_node := current_scene.get_node_or_null("YSortWorld/Characters/Players/Player") as Node2D
if player_node != null:
return_position = player_node.global_position
SceneManager.begin_room_visit(owner_id, nickname, return_scene, return_position)
_close_profile()
SceneManager.change_scene("personal_space")
func request_travel(destination_id: String, completion: Callable = Callable()) -> void:
_request_post("/world/travel-destinations/%s/travel" % destination_id.uri_encode(), {}, func(success: bool, response: Dictionary, error: Dictionary) -> void:
if not success:
_show_status(str(error.get("message", "该地点尚未解锁")))
if completion.is_valid(): completion.call(false, {})
return
var data_variant: Variant = response.get("data", {})
var data: Dictionary = data_variant if data_variant is Dictionary else {}
if completion.is_valid(): completion.call(true, data)
)
func discover_destination(destination_id: String) -> void:
var normalized := destination_id.strip_edges()
if normalized.is_empty() or _discoveredDestinations.has(normalized) or _discoveryRequests.has(normalized):
return
_discoveryRequests[normalized] = true
_request_post("/world/travel-destinations/%s/discover" % normalized.uri_encode(), {}, func(success: bool, _response: Dictionary, _error: Dictionary) -> void:
_discoveryRequests.erase(normalized)
if success:
_discoveredDestinations[normalized] = true
)
func discover_nearby_destinations(map_id: String, position: Vector2) -> void:
for point in DISCOVERY_POINTS:
if str(point.get("mapId", "")) != map_id:
continue
var target := point.get("position", Vector2.ZERO) as Vector2
if position.distance_to(target) <= DISCOVERY_DISTANCE:
discover_destination(str(point.get("id", "")))
func toggle_notifications() -> void:
_notification_panel.visible = not _notification_panel.visible
if _notification_panel.visible:
_refresh_notifications()
func is_escape_dismissible() -> bool:
return (is_instance_valid(_profile_panel) and _profile_panel.visible) or (is_instance_valid(_notification_panel) and _notification_panel.visible)
func get_escape_priority() -> int:
return 800
func request_escape_close() -> void:
if is_instance_valid(_profile_panel) and _profile_panel.visible:
_close_profile()
return
if is_instance_valid(_notification_panel):
_notification_panel.visible = false
func _on_social_realtime(_payload: Dictionary) -> void:
if _notification_panel.visible:
_refresh_notifications()
func _refresh_notifications() -> void:
_request_get("/social/notifications?limit=30", func(success: bool, response: Dictionary, error: Dictionary) -> void:
if not success:
_show_notification_rows(["通知暂时无法读取:%s" % str(error.get("message", "请求失败"))])
return
var data_variant: Variant = response.get("data", {})
var data: Dictionary = data_variant if data_variant is Dictionary else {}
var rows: Array[String] = []
var notifications_variant: Variant = data.get("notifications", [])
if notifications_variant is Array:
for item in notifications_variant as Array:
if item is Dictionary:
var notification: Dictionary = item
rows.append("%s\n%s" % [str(notification.get("title", "通知")), str(notification.get("content", ""))])
if rows.is_empty(): rows.append("暂无通知")
_show_notification_rows(rows)
)
func _render_profile() -> void:
_profile_panel.visible = true
for child in _profile_content.get_children():
child.queue_free()
var nickname := str(_current_profile.get("nickname", "玩家"))
var username := str(_current_profile.get("username", ""))
var online := bool(_current_profile.get("online", false))
var area := str(_current_profile.get("currentArea", ""))
var identity := HBoxContainer.new()
identity.add_theme_constant_override("separation", 12)
identity.add_child(_avatar_badge(nickname))
var identity_text := VBoxContainer.new()
identity_text.size_flags_horizontal = Control.SIZE_EXPAND_FILL
identity_text.add_child(_label(nickname, 26, ACCENT))
identity_text.add_child(_label("@%s · %s" % [username, "在线" if online else "离线"], 15, Color(0.72, 0.82, 0.87, 1)))
identity.add_child(identity_text)
_profile_content.add_child(identity)
_profile_content.add_child(_label("当前区域:%s" % _area_label(area), 16, Color(0.88, 0.94, 0.96, 1)))
var skin_id := str(_current_profile.get("skinId", "")).strip_edges()
_profile_content.add_child(_label("角色:%s" % (skin_id if not skin_id.is_empty() else "默认角色"), 14, Color(0.50, 0.76, 0.80, 1)))
var bio := str(_current_profile.get("bio", "")).strip_edges()
_profile_content.add_child(_label(bio if not bio.is_empty() else "这个玩家还没有留下简介。", 16, Color(0.84, 0.90, 0.92, 1)))
var interests_variant: Variant = _current_profile.get("interests", [])
var interests: Array[String] = []
if interests_variant is Array:
for interest in interests_variant as Array: interests.append(str(interest))
_profile_content.add_child(_label("兴趣:%s" % (" · ".join(interests) if not interests.is_empty() else "未设置"), 14, Color(0.50, 0.76, 0.80, 1)))
var user_id := str(_current_profile.get("id", ""))
var self_profile := user_id == _current_user_id()
if self_profile:
_profile_content.add_child(_label("编辑社区资料", 17, ACCENT))
var nickname_input := LineEdit.new()
nickname_input.placeholder_text = "昵称(首次可立即修改,此后每 7 天一次)"
nickname_input.text = nickname
_profile_content.add_child(nickname_input)
var bio_input := TextEdit.new()
bio_input.placeholder_text = "简介(最多 160 字)"
bio_input.text = bio
bio_input.custom_minimum_size = Vector2(0, 82)
_profile_content.add_child(bio_input)
var current_interests: Array[String] = []
for interest in interests: current_interests.append(interest)
var interest_row := HBoxContainer.new()
interest_row.add_theme_constant_override("separation", 6)
var interest_selects: Array[OptionButton] = []
for slot in 3:
var selector := OptionButton.new()
selector.custom_minimum_size = Vector2(145, 34)
selector.add_item("兴趣标签")
for tag in _interest_catalog():
selector.add_item(str(tag.get("label", "")))
selector.set_item_metadata(selector.item_count - 1, str(tag.get("id", "")))
if slot < current_interests.size():
for item_index in range(1, selector.item_count):
if str(selector.get_item_metadata(item_index)) == current_interests[slot]:
selector.select(item_index)
break
interest_selects.append(selector)
interest_row.add_child(selector)
_profile_content.add_child(interest_row)
_profile_content.add_child(_button("保存资料", func() -> void: _save_own_profile(nickname_input.text, bio_input.text, interest_selects)))
else:
var actions := HBoxContainer.new()
actions.add_theme_constant_override("separation", 8)
actions.add_child(_button("私聊", func() -> void: open_private_chat(user_id, nickname)))
actions.add_child(_button("加好友", func() -> void: request_friend(user_id, nickname)))
if bool(_current_profile.get("room_visitable", false)):
actions.add_child(_button("访问房间", func() -> void: visit_room(user_id, nickname)))
_profile_content.add_child(actions)
var safety := HBoxContainer.new()
safety.add_theme_constant_override("separation", 8)
safety.add_child(_button("拉黑", func() -> void: _block_user(user_id)))
var report_form := _report_form(user_id)
var report_button := _button("举报", func() -> void: report_form.visible = not report_form.visible)
safety.add_child(report_button)
_profile_content.add_child(safety)
_profile_content.add_child(report_form)
_profile_content.add_child(_button("关闭", _close_profile))
func _block_user(user_id: String) -> void:
_request_post("/social/blocks", {"userId": user_id}, func(success: bool, _response: Dictionary, error: Dictionary) -> void:
_show_status("已拉黑该玩家" if success else str(error.get("message", "拉黑失败")))
if success: _close_profile()
)
func _save_own_profile(nickname: String, bio: String, interest_selects: Array[OptionButton]) -> void:
var interests: Array[String] = []
for selector in interest_selects:
var index := selector.selected
if index > 0:
var value := str(selector.get_item_metadata(index)).strip_edges()
if not value.is_empty() and not interests.has(value): interests.append(value)
_request_patch("/social/profile", {"nickname": nickname.strip_edges(), "bio": bio.strip_edges(), "interests": interests}, func(success: bool, response: Dictionary, error: Dictionary) -> void:
if not success:
_show_status(str(error.get("message", "资料保存失败")))
return
var data_variant: Variant = response.get("data", {})
if data_variant is Dictionary:
_current_profile = data_variant as Dictionary
_render_profile()
var event_system := get_node_or_null("/root/EventSystem")
if event_system != null: event_system.call("emit_event", EventNames.SOCIAL_PROFILE_UPDATED, _current_profile)
)
func _report_form(user_id: String) -> VBoxContainer:
var form := VBoxContainer.new()
form.visible = false
form.add_theme_constant_override("separation", 6)
form.add_theme_stylebox_override("panel", _subpanel_style())
form.add_child(_label("举报原因", 15, ACCENT))
var reason := OptionButton.new()
for item in [
{"id": "harassment", "label": "骚扰或辱骂"},
{"id": "spam", "label": "垃圾信息"},
{"id": "inappropriate_content", "label": "不当内容"},
{"id": "impersonation", "label": "冒充他人"},
{"id": "other", "label": "其他"},
]:
reason.add_item(str(item.get("label", "其他")))
reason.set_item_metadata(reason.item_count - 1, str(item.get("id", "other")))
form.add_child(reason)
var note := TextEdit.new()
note.placeholder_text = "补充说明(可选,最多 500 字)"
note.custom_minimum_size = Vector2(0, 68)
form.add_child(note)
var block_also := CheckBox.new()
block_also.text = "同时拉黑此玩家"
block_also.button_pressed = false
form.add_child(block_also)
form.add_child(_button("提交举报", func() -> void:
_submit_report(user_id, str(reason.get_item_metadata(reason.selected)), note.text, block_also.button_pressed)
))
return form
func _submit_report(user_id: String, reason: String, note: String, block_also: bool) -> void:
_request_post("/social/reports", {
"userId": user_id,
"reason": reason,
"note": note.strip_edges(),
"blockAlso": block_also,
}, func(success: bool, _response: Dictionary, error: Dictionary) -> void:
_show_status("举报已提交" if success else str(error.get("message", "举报失败")))
if success and block_also:
_close_profile()
)
func _build_ui() -> void:
_canvas = CanvasLayer.new()
_canvas.layer = 88
add_child(_canvas)
_profile_panel = _panel(Vector2(540, 610))
_profile_panel.set_anchors_preset(Control.PRESET_CENTER)
_profile_panel.position = Vector2(-270, -305)
_canvas.add_child(_profile_panel)
var profile_margin := _margin(_profile_panel)
var profile_scroll := ScrollContainer.new()
profile_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
profile_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
profile_margin.add_child(profile_scroll)
_profile_content = VBoxContainer.new()
_profile_content.add_theme_constant_override("separation", 12)
_profile_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
profile_scroll.add_child(_profile_content)
_notification_panel = _panel(Vector2(390, 430))
_notification_panel.set_anchors_preset(Control.PRESET_TOP_RIGHT)
_notification_panel.position = Vector2(-414, 110)
_canvas.add_child(_notification_panel)
var notification_margin := _margin(_notification_panel)
_notification_content = VBoxContainer.new()
_notification_content.add_theme_constant_override("separation", 9)
notification_margin.add_child(_notification_content)
_notification_panel.visible = false
_profile_panel.visible = false
func _show_notification_rows(rows: Array[String]) -> void:
for child in _notification_content.get_children(): child.queue_free()
_notification_content.add_child(_label("通知中心", 22, ACCENT))
for row in rows:
_notification_content.add_child(_label(row, 15, Color(0.87, 0.93, 0.95, 1)))
_notification_content.add_child(_button("全部标为已读", func() -> void:
_request_patch("/social/notifications/read-all", {}, func(_success: bool, _response: Dictionary, _error: Dictionary) -> void: _refresh_notifications())
))
func _panel(panel_size: Vector2) -> PanelContainer:
var panel := PanelContainer.new()
panel.size = panel_size
panel.mouse_filter = Control.MOUSE_FILTER_STOP
var style := StyleBoxFlat.new()
style.bg_color = PANEL_COLOR
style.border_color = Color(0.20, 0.52, 0.61, 0.9)
style.set_border_width_all(1)
style.set_corner_radius_all(12)
style.shadow_color = Color(0, 0, 0, 0.45)
style.shadow_size = 16
panel.add_theme_stylebox_override("panel", style)
return panel
func _margin(parent: Control) -> MarginContainer:
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 20)
margin.add_theme_constant_override("margin_top", 18)
margin.add_theme_constant_override("margin_right", 20)
margin.add_theme_constant_override("margin_bottom", 18)
parent.add_child(margin)
return margin
func _label(text: String, font_size: int, color: Color) -> Label:
var label := Label.new()
label.text = text
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.add_theme_font_size_override("font_size", font_size)
label.add_theme_color_override("font_color", color)
return label
func _button(text: String, callback: Callable) -> Button:
var button := Button.new()
button.text = text
button.custom_minimum_size = Vector2(92, 34)
button.pressed.connect(callback)
return button
func _avatar_badge(nickname: String) -> Label:
var badge := Label.new()
badge.text = nickname.left(1).to_upper() if not nickname.is_empty() else ""
badge.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
badge.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
badge.custom_minimum_size = Vector2(52, 52)
badge.add_theme_font_size_override("font_size", 22)
badge.add_theme_color_override("font_color", Color(0.92, 0.99, 1.0, 1.0))
var style := StyleBoxFlat.new()
style.bg_color = Color(0.12, 0.40, 0.48, 1.0)
style.set_corner_radius_all(26)
style.border_color = ACCENT
style.set_border_width_all(1)
badge.add_theme_stylebox_override("normal", style)
return badge
func _subpanel_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.06, 0.13, 0.18, 0.88)
style.set_border_width_all(1)
style.border_color = Color(0.18, 0.45, 0.52, 0.75)
style.set_corner_radius_all(8)
style.content_margin_left = 10
style.content_margin_right = 10
style.content_margin_top = 8
style.content_margin_bottom = 8
return style
func _close_profile() -> void:
_profile_panel.visible = false
func _show_status(message: String) -> void:
push_warning("Social: %s" % message)
func _request_get(endpoint: String, callback: Callable) -> void:
var api := get_node_or_null("/root/ApiClient")
if api != null: api.call("get_json", endpoint, callback, true)
func _request_post(endpoint: String, payload: Dictionary, callback: Callable) -> void:
var api := get_node_or_null("/root/ApiClient")
if api != null: api.call("post_json", endpoint, payload, callback, true)
func _request_patch(endpoint: String, payload: Dictionary, callback: Callable) -> void:
var api := get_node_or_null("/root/ApiClient")
if api != null: api.call("patch_json", endpoint, payload, callback, true)
func _current_user_id() -> String:
var auth := get_node_or_null("/root/AuthManager")
if auth != null and auth.has_method("get_current_user"):
var user_variant: Variant = auth.call("get_current_user")
if user_variant is Dictionary: return str((user_variant as Dictionary).get("id", ""))
return ""
func _area_label(map_id: String) -> String:
return {"whale_port": "中心广场", "work_zone": "打工区", "whale_cafe": "鲸鱼咖啡馆", "personal_space": "个人空间"}.get(map_id, map_id)
func _scene_name_to_id(scene_name: String) -> String:
return {
"Square": "square",
"WorkZone": "work_zone",
"CafeInterior": "cafe_interior",
"PersonalSpace": "personal_space",
}.get(scene_name, "")
func _interest_catalog() -> Array[Dictionary]:
return [
{"id": "ai", "label": "AI/大模型"}, {"id": "programming", "label": "编程开发"},
{"id": "data_science", "label": "数据科学"}, {"id": "open_source", "label": "开源协作"},
{"id": "product", "label": "产品"}, {"id": "design", "label": "设计"},
{"id": "game_dev", "label": "游戏开发"}, {"id": "content_creation", "label": "内容创作"},
{"id": "community", "label": "社区活动"}, {"id": "learning_partner", "label": "学习搭子"},
{"id": "career", "label": "职业成长"}, {"id": "casual_chat", "label": "轻松闲聊"},
]

View File

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

View File

@@ -0,0 +1,181 @@
extends Node
# 玩家任务书状态与后端任务接口的唯一入口。
signal board_changed(board: Dictionary)
signal board_failed(message: String)
signal reward_claimed(task_id: String, wallet: Dictionary)
var _board: Dictionary = {}
var _loading_board: bool = false
var _account_generation: int = -1
var _recent_activity_at: Dictionary = {}
const ACTIVITY_DEBOUNCE_MSEC: int = 500
const MAX_ACTIVITY_RETRIES: int = 3
const ACTIVITY_RETRY_BASE_SECONDS: float = 0.5
func _ready() -> void:
var auth_manager := get_node_or_null("/root/AuthManager")
if auth_manager != null and auth_manager.has_signal("auth_state_changed"):
var callback := Callable(self, "_on_auth_state_changed")
if not auth_manager.is_connected("auth_state_changed", callback):
auth_manager.connect("auth_state_changed", callback)
call_deferred("_refresh_after_ready")
func get_board() -> Dictionary:
return _board.duplicate(true)
func is_loaded() -> bool:
return not _board.is_empty()
func refresh_board() -> void:
if not _is_authenticated() or _loading_board:
return
var api := _api_client()
if api == null:
board_failed.emit("任务服务不可用")
return
_loading_board = true
_account_generation = _current_account_generation()
api.call("get_json", "/tasks/board", Callable(self, "_on_board_response").bind(_account_generation), true)
func report_activity(activity: String, target_id: String = "") -> void:
if not _is_authenticated():
return
var normalized_target_id := target_id.strip_edges()
var activity_key := "%s:%s" % [activity, normalized_target_id]
var now_msec := Time.get_ticks_msec()
if now_msec - int(_recent_activity_at.get(activity_key, 0)) < ACTIVITY_DEBOUNCE_MSEC:
return
_recent_activity_at[activity_key] = now_msec
_send_activity(activity, normalized_target_id, 0, _current_account_generation())
func _send_activity(activity: String, target_id: String, retry_count: int, request_generation: int) -> void:
if request_generation != _current_account_generation() or not _is_authenticated():
return
var api := _api_client()
if api == null:
return
var payload := {
"activity": activity,
"nonce": _create_activity_nonce(),
"occurred_at": Time.get_datetime_string_from_system(true, false),
}
if not target_id.is_empty():
payload["target_id"] = target_id
api.call(
"post_json",
"/tasks/activities",
payload,
Callable(self, "_on_activity_response").bind(activity, target_id, retry_count, request_generation),
true
)
func claim_task(task_id: String) -> void:
if task_id.strip_edges().is_empty() or not _is_authenticated():
return
var api := _api_client()
if api == null:
board_failed.emit("任务服务不可用")
return
var request_generation := _current_account_generation()
api.call("post_json", "/tasks/%s/claim" % task_id.uri_encode(), {}, Callable(self, "_on_claim_response").bind(task_id, request_generation), true)
func _refresh_after_ready() -> void:
if _is_authenticated():
refresh_board()
func _on_auth_state_changed(is_authenticated: bool, _user: Dictionary) -> void:
if not is_authenticated:
_board.clear()
_recent_activity_at.clear()
_loading_board = false
_account_generation = -1
board_changed.emit({})
return
refresh_board()
func _on_board_response(success: bool, response: Dictionary, error_info: Dictionary, request_generation: int) -> void:
if request_generation != _current_account_generation():
return
_loading_board = false
if not success:
board_failed.emit(str(error_info.get("message", "任务书读取失败")))
return
_apply_board_response(response)
func _on_activity_response(
success: bool,
response: Dictionary,
error_info: Dictionary,
activity: String,
target_id: String,
retry_count: int,
request_generation: int
) -> void:
if request_generation != _current_account_generation():
return
if success:
_apply_board_response(response)
return
if retry_count >= MAX_ACTIVITY_RETRIES or not _is_retriable_session_conflict(error_info):
return
var delay := ACTIVITY_RETRY_BASE_SECONDS * pow(2.0, retry_count)
get_tree().create_timer(delay).timeout.connect(
Callable(self, "_send_activity").bind(activity, target_id, retry_count + 1, request_generation),
CONNECT_ONE_SHOT
)
func _is_retriable_session_conflict(error_info: Dictionary) -> bool:
if int(error_info.get("response_code", 0)) != 409:
return false
var message := str(error_info.get("message", ""))
return message.contains("会话") or message.contains("位置") or message.contains("交互范围")
func _on_claim_response(success: bool, response: Dictionary, error_info: Dictionary, task_id: String, request_generation: int) -> void:
if request_generation != _current_account_generation():
return
if not success:
board_failed.emit(str(error_info.get("message", "奖励领取失败")))
return
var data_variant: Variant = response.get("data", {})
if not (data_variant is Dictionary):
board_failed.emit("任务奖励响应格式错误")
return
var data: Dictionary = data_variant as Dictionary
var board_variant: Variant = data.get("board", {})
if board_variant is Dictionary:
_apply_board(board_variant as Dictionary)
var wallet_variant: Variant = data.get("wallet", {})
if wallet_variant is Dictionary:
var wallet: Dictionary = wallet_variant as Dictionary
var player_state := get_node_or_null("/root/PlayerStateManager")
if player_state != null and player_state.has_method("apply_wallet"):
player_state.call("apply_wallet", wallet)
reward_claimed.emit(task_id, wallet.duplicate(true))
func _apply_board_response(response: Dictionary) -> void:
var data_variant: Variant = response.get("data", {})
if data_variant is Dictionary:
_apply_board(data_variant as Dictionary)
else:
board_failed.emit("任务书响应格式错误")
func _apply_board(board: Dictionary) -> void:
_board = board.duplicate(true)
board_changed.emit(get_board())
func _api_client() -> Node:
return get_node_or_null("/root/ApiClient")
func _is_authenticated() -> bool:
var auth_manager := get_node_or_null("/root/AuthManager")
return auth_manager != null and auth_manager.has_method("is_authenticated") and bool(auth_manager.call("is_authenticated"))
func _current_account_generation() -> int:
var auth_manager := get_node_or_null("/root/AuthManager")
return int(auth_manager.call("get_account_generation")) if auth_manager != null and auth_manager.has_method("get_account_generation") else 0
func _create_activity_nonce() -> String:
var random_bytes := Crypto.new().generate_random_bytes(16)
return random_bytes.hex_encode()

View File

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

View File

@@ -0,0 +1,135 @@
extends Node
# 游戏内管理员测试实验室的唯一 API 入口。
# 客户端只把 role=9 用于显示入口;实际授权仍由后端 TestLabGuard 完成。
signal status_changed(status: Dictionary)
signal status_failed(message: String)
signal operation_completed(message: String)
var _status: Dictionary = {}
var _loading_status: bool = false
var _account_generation: int = -1
func _ready() -> void:
var auth_manager := get_node_or_null("/root/AuthManager")
if auth_manager != null and auth_manager.has_signal("auth_state_changed"):
var callback := Callable(self, "_on_auth_state_changed")
if not auth_manager.is_connected("auth_state_changed", callback):
auth_manager.connect("auth_state_changed", callback)
call_deferred("_refresh_after_ready")
func get_status() -> Dictionary:
return _status.duplicate(true)
func is_loaded() -> bool:
return not _status.is_empty()
func is_admin() -> bool:
var auth_manager := get_node_or_null("/root/AuthManager")
if auth_manager == null or not auth_manager.has_method("is_authenticated") or not bool(auth_manager.call("is_authenticated")):
return false
if not auth_manager.has_method("get_current_user"):
return false
var user_variant: Variant = auth_manager.call("get_current_user")
if not (user_variant is Dictionary):
return false
var user: Dictionary = user_variant as Dictionary
return int(user.get("role", 0)) == 9
func refresh_status() -> void:
if not is_admin() or _loading_status:
return
var api := _api_client()
if api == null:
status_failed.emit("测试实验室服务不可用")
return
_loading_status = true
_account_generation = _current_account_generation()
api.call("get_json", "/admin/test-lab/status", Callable(self, "_on_status_response").bind(_account_generation), true)
func create_actor(nickname: String, map_id: String, x: int, y: int, skin_id: String) -> void:
_mutate("post_json", "/admin/test-lab/actors", {
"nickname": nickname.strip_edges(), "mapId": map_id, "x": x, "y": y, "skinId": skin_id
}, "测试假人已上线")
func update_actor(user_id: String, online: bool, map_id: String, x: int, y: int, skin_id: String) -> void:
_mutate("patch_json", "/admin/test-lab/actors/%s" % user_id.uri_encode(), {
"online": online, "mapId": map_id, "x": x, "y": y, "skinId": skin_id
}, "假人状态已同步")
func set_room_policy(user_id: String, policy: String) -> void:
_mutate("patch_json", "/admin/test-lab/actors/%s/room-policy" % user_id.uri_encode(), {
"roomVisitPolicy": policy
}, "房间访问策略已更新")
func send_message(user_id: String, scope: String, content: String, target_user_id: String = "") -> void:
var payload := {"scope": scope, "content": content.strip_edges()}
if scope == "private":
payload["targetUserId"] = target_user_id
_mutate("post_json", "/admin/test-lab/actors/%s/messages" % user_id.uri_encode(), payload, "测试消息已发送")
func social_action(user_id: String, action: String, target_user_id: String) -> void:
_mutate("post_json", "/admin/test-lab/actors/%s/social" % user_id.uri_encode(), {
"action": action, "targetUserId": target_user_id
}, "社交操作已执行")
func delete_actor(user_id: String) -> void:
_mutate("delete_json", "/admin/test-lab/actors/%s" % user_id.uri_encode(), {}, "测试假人已删除")
func clear_all() -> void:
_mutate("delete_json", "/admin/test-lab/actors", {}, "测试实验室已清空")
func _mutate(method_name: String, endpoint: String, payload: Dictionary, success_message: String) -> void:
if not is_admin():
status_failed.emit("仅管理员可使用测试实验室")
return
var api := _api_client()
if api == null:
status_failed.emit("测试实验室服务不可用")
return
var request_generation := _current_account_generation()
api.call(method_name, endpoint, payload, Callable(self, "_on_mutation_response").bind(success_message, request_generation), true)
func _refresh_after_ready() -> void:
if is_admin():
refresh_status()
func _on_auth_state_changed(is_authenticated: bool, _user: Dictionary) -> void:
if not is_authenticated or not is_admin():
_status.clear()
_loading_status = false
_account_generation = -1
status_changed.emit({})
return
refresh_status()
func _on_status_response(success: bool, response: Dictionary, error_info: Dictionary, request_generation: int) -> void:
if request_generation != _current_account_generation():
return
_loading_status = false
if not success:
status_failed.emit(str(error_info.get("message", "测试实验室读取失败")))
return
var data_variant: Variant = response.get("data", {})
if not (data_variant is Dictionary):
status_failed.emit("测试实验室响应格式错误")
return
_status = (data_variant as Dictionary).duplicate(true)
status_changed.emit(get_status())
func _on_mutation_response(success: bool, _response: Dictionary, error_info: Dictionary, success_message: String, request_generation: int) -> void:
if request_generation != _current_account_generation():
return
if not success:
status_failed.emit(str(error_info.get("message", "测试实验室操作失败")))
return
operation_completed.emit(success_message)
refresh_status()
func _api_client() -> Node:
return get_node_or_null("/root/ApiClient")
func _current_account_generation() -> int:
var auth_manager := get_node_or_null("/root/AuthManager")
return int(auth_manager.call("get_account_generation")) if auth_manager != null and auth_manager.has_method("get_account_generation") else 0

View File

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

View File

@@ -0,0 +1,58 @@
extends Node
# 统一处理 UI 的 Esc 退出。可关闭界面加入 whaletown_escape_dismissible 分组,
# 并实现 is_escape_dismissible / get_escape_priority / request_escape_close。
const ESCAPE_DISMISSIBLE_GROUP: StringName = &"whaletown_escape_dismissible"
func _ready() -> void:
# 公告、排行榜等界面会暂停场景树Esc 仍必须有效。
process_mode = Node.PROCESS_MODE_ALWAYS
func _input(event: InputEvent) -> void:
if not (event is InputEventKey):
return
var key_event: InputEventKey = event as InputEventKey
if not key_event.pressed or key_event.echo or key_event.keycode != KEY_ESCAPE:
return
if _hide_open_popup_menu() or _close_topmost_dismissible() or _release_control_focus():
get_viewport().set_input_as_handled()
func _hide_open_popup_menu() -> bool:
var popup_nodes: Array[Node] = get_tree().root.find_children("*", "PopupMenu", true, false)
for popup_node: Node in popup_nodes:
var popup: PopupMenu = popup_node as PopupMenu
if popup != null and popup.visible:
popup.hide()
return true
return false
func _close_topmost_dismissible() -> bool:
var target: Node = null
var target_priority: int = -2147483648
var dismissibles: Array[Node] = get_tree().get_nodes_in_group(ESCAPE_DISMISSIBLE_GROUP)
for dismissible: Node in dismissibles:
if not is_instance_valid(dismissible):
continue
if not dismissible.has_method("is_escape_dismissible") or not dismissible.has_method("request_escape_close"):
continue
var is_dismissible_variant: Variant = dismissible.call("is_escape_dismissible")
if not bool(is_dismissible_variant):
continue
var priority: int = 0
if dismissible.has_method("get_escape_priority"):
var priority_variant: Variant = dismissible.call("get_escape_priority")
priority = int(priority_variant)
if target == null or priority > target_priority:
target = dismissible
target_priority = priority
if target == null:
return false
target.call("request_escape_close")
return true
func _release_control_focus() -> bool:
var focus_owner: Control = get_viewport().gui_get_focus_owner()
if focus_owner == null:
return false
focus_owner.release_focus()
return true

View File

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

View File

@@ -0,0 +1,92 @@
extends RefCounted
var _parent: Node2D
var _collision_layer: int
var _collision_mask: int
var _bodies: Dictionary = {}
func _init(parent: Node2D, collision_layer: int, collision_mask: int) -> void:
_parent = parent
_collision_layer = collision_layer
_collision_mask = collision_mask
func sync(decor_id: String, item: Dictionary) -> void:
var collision_size := _to_vector2(item.get("collision_size", Vector2.ZERO))
if collision_size == Vector2.ZERO:
remove(decor_id)
return
var collision_offset := _to_vector2(item.get("collision_offset", Vector2.ZERO))
var body := _bodies.get(decor_id, null) as StaticBody2D
var shape_node: CollisionShape2D
if body == null or not is_instance_valid(body):
body = StaticBody2D.new()
body.name = "DecorCollision_%s" % decor_id
body.collision_layer = _collision_layer
body.collision_mask = _collision_mask
shape_node = CollisionShape2D.new()
shape_node.name = "CollisionShape2D"
shape_node.shape = RectangleShape2D.new()
body.add_child(shape_node)
_parent.add_child(body)
_bodies[decor_id] = body
else:
shape_node = body.get_node_or_null("CollisionShape2D") as CollisionShape2D
if shape_node == null:
shape_node = CollisionShape2D.new()
shape_node.name = "CollisionShape2D"
shape_node.shape = RectangleShape2D.new()
body.add_child(shape_node)
var scale := _to_float(item.get("scale", item.get("default_scale", 1.0)), 1.0)
var rotation_degrees := _to_float(
item.get("rotation_degrees", item.get("default_rotation_degrees", 0.0)),
0.0
)
body.rotation_degrees = rotation_degrees
body.global_position = Vector2(
_to_float(item.get("position_x", 0.0), 0.0),
_to_float(item.get("position_y", 0.0), 0.0)
) + collision_offset.rotated(deg_to_rad(rotation_degrees)) * scale
shape_node.position = Vector2.ZERO
var rectangle := shape_node.shape as RectangleShape2D
if rectangle == null:
rectangle = RectangleShape2D.new()
shape_node.shape = rectangle
rectangle.size = Vector2(maxf(8.0, collision_size.x * scale), maxf(8.0, collision_size.y * scale))
func set_disabled(decor_id: String, disabled: bool) -> void:
var body := _bodies.get(decor_id, null) as StaticBody2D
if body == null or not is_instance_valid(body):
return
var shape_node := body.get_node_or_null("CollisionShape2D") as CollisionShape2D
if shape_node != null:
shape_node.disabled = disabled
func remove(decor_id: String) -> void:
var body := _bodies.get(decor_id, null) as Node
if body != null and is_instance_valid(body):
body.queue_free()
_bodies.erase(decor_id)
func _to_vector2(value: Variant) -> Vector2:
if value is Vector2:
return value
if value is Dictionary:
var dictionary: Dictionary = value
return Vector2(
_to_float(dictionary.get("x", 0.0), 0.0),
_to_float(dictionary.get("y", 0.0), 0.0)
)
return Vector2.ZERO
func _to_float(value: Variant, fallback: float) -> float:
match typeof(value):
TYPE_FLOAT, TYPE_INT:
return float(value)
TYPE_STRING:
var text := str(value).strip_edges()
return fallback if text.is_empty() else text.to_float()
TYPE_BOOL:
return 1.0 if bool(value) else 0.0
_:
return fallback

View File

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

View File

@@ -0,0 +1,60 @@
extends RefCounted
var _undo_entries: Array[Dictionary] = []
var _redo_entries: Array[Dictionary] = []
func clear() -> void:
_undo_entries.clear()
_redo_entries.clear()
func record_item(before: Dictionary, after: Dictionary) -> bool:
if before == after:
return false
var decor_id := str(after.get("decor_id", before.get("decor_id", ""))).strip_edges()
if decor_id.is_empty():
return false
_undo_entries.append({
"decor_id": decor_id,
"before": before.duplicate(true),
"after": after.duplicate(true),
})
_redo_entries.clear()
return true
func record_bulk(before_items: Dictionary, after_items: Dictionary) -> bool:
if before_items == after_items:
return false
_undo_entries.append({
"before_items": _duplicate_item_map(before_items),
"after_items": _duplicate_item_map(after_items),
})
_redo_entries.clear()
return true
func can_undo() -> bool:
return not _undo_entries.is_empty()
func can_redo() -> bool:
return not _redo_entries.is_empty()
func take_undo() -> Dictionary:
if _undo_entries.is_empty():
return {}
var entry: Dictionary = _undo_entries.pop_back()
_redo_entries.append(entry)
return entry
func take_redo() -> Dictionary:
if _redo_entries.is_empty():
return {}
var entry: Dictionary = _redo_entries.pop_back()
_undo_entries.append(entry)
return entry
func _duplicate_item_map(source: Dictionary) -> Dictionary:
var duplicate: Dictionary = {}
for decor_id_variant in source.keys():
var item_variant: Variant = source.get(decor_id_variant, {})
if item_variant is Dictionary:
duplicate[str(decor_id_variant)] = (item_variant as Dictionary).duplicate(true)
return duplicate

View File

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

View File

@@ -0,0 +1,130 @@
extends RefCounted
const STORAGE_VERSION: int = 2
const BLOCK_SIZE: int = 16
const KEY_CONTEXT: String = "WhaleTown-V2/session-store/v1"
func save_refresh_token(path: String, refresh_token: String) -> bool:
if refresh_token.is_empty():
clear(path)
return true
var keys := _derive_keys()
if keys.is_empty():
return false
var iv := Crypto.new().generate_random_bytes(BLOCK_SIZE)
var encryption_key: PackedByteArray = keys.get("encryption", PackedByteArray())
var authentication_key: PackedByteArray = keys.get("authentication", PackedByteArray())
var encrypted := _encrypt(refresh_token.to_utf8_buffer(), encryption_key, iv)
if encrypted.is_empty():
return false
var authenticated_data := iv.duplicate()
authenticated_data.append_array(encrypted)
var mac := Crypto.new().hmac_digest(HashingContext.HASH_SHA256, authentication_key, authenticated_data)
var payload := {
"version": STORAGE_VERSION,
"iv": Marshalls.raw_to_base64(iv),
"ciphertext": Marshalls.raw_to_base64(encrypted),
"mac": Marshalls.raw_to_base64(mac),
"saved_at": Time.get_unix_time_from_system(),
}
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return false
file.store_string(JSON.stringify(payload))
file.close()
return true
func load_refresh_token(path: String) -> String:
if not FileAccess.file_exists(path):
return ""
var keys := _derive_keys()
if keys.is_empty():
clear(path)
return ""
var json := JSON.new()
if json.parse(FileAccess.get_file_as_string(path)) != OK or not (json.data is Dictionary):
clear(path)
return ""
var payload: Dictionary = json.data
if int(payload.get("version", 0)) != STORAGE_VERSION:
clear(path)
return ""
var iv := Marshalls.base64_to_raw(str(payload.get("iv", "")))
var encrypted := Marshalls.base64_to_raw(str(payload.get("ciphertext", "")))
var expected_mac := Marshalls.base64_to_raw(str(payload.get("mac", "")))
if iv.size() != BLOCK_SIZE or encrypted.is_empty() or expected_mac.is_empty():
clear(path)
return ""
var authenticated_data := iv.duplicate()
authenticated_data.append_array(encrypted)
var encryption_key: PackedByteArray = keys.get("encryption", PackedByteArray())
var authentication_key: PackedByteArray = keys.get("authentication", PackedByteArray())
var actual_mac := Crypto.new().hmac_digest(HashingContext.HASH_SHA256, authentication_key, authenticated_data)
if not _constant_time_equals(actual_mac, expected_mac):
clear(path)
return ""
var decrypted := _decrypt(encrypted, encryption_key, iv)
return decrypted.get_string_from_utf8().strip_edges()
func clear(path: String) -> void:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
func _derive_keys() -> Dictionary:
if OS.get_name() == "Web":
return {}
var device_id := OS.get_unique_id().strip_edges()
if device_id.is_empty():
return {}
var root_key := _sha256((KEY_CONTEXT + ":" + device_id).to_utf8_buffer())
if root_key.is_empty():
return {}
return {
"encryption": _sha256(root_key + ":encryption".to_utf8_buffer()),
"authentication": _sha256(root_key + ":authentication".to_utf8_buffer()),
}
func _sha256(bytes: PackedByteArray) -> PackedByteArray:
var hash_context := HashingContext.new()
if hash_context.start(HashingContext.HASH_SHA256) != OK:
return PackedByteArray()
if hash_context.update(bytes) != OK:
return PackedByteArray()
return hash_context.finish()
func _encrypt(plain_text: PackedByteArray, key: PackedByteArray, iv: PackedByteArray) -> PackedByteArray:
var padded := plain_text.duplicate()
var padding_size := BLOCK_SIZE - (padded.size() % BLOCK_SIZE)
for _index in range(padding_size):
padded.append(padding_size)
var aes := AESContext.new()
if aes.start(AESContext.MODE_CBC_ENCRYPT, key, iv) != OK:
return PackedByteArray()
var encrypted := aes.update(padded)
aes.finish()
return encrypted
func _decrypt(encrypted: PackedByteArray, key: PackedByteArray, iv: PackedByteArray) -> PackedByteArray:
var aes := AESContext.new()
if aes.start(AESContext.MODE_CBC_DECRYPT, key, iv) != OK:
return PackedByteArray()
var padded := aes.update(encrypted)
aes.finish()
if padded.is_empty():
return PackedByteArray()
var padding_size := int(padded[padded.size() - 1])
if padding_size < 1 or padding_size > BLOCK_SIZE or padding_size > padded.size():
return PackedByteArray()
for index in range(padded.size() - padding_size, padded.size()):
if int(padded[index]) != padding_size:
return PackedByteArray()
padded.resize(padded.size() - padding_size)
return padded
func _constant_time_equals(left: PackedByteArray, right: PackedByteArray) -> bool:
if left.size() != right.size():
return false
var difference := 0
for index in range(left.size()):
difference |= int(left[index]) ^ int(right[index])
return difference == 0

View File

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

View File

@@ -0,0 +1,20 @@
extends Node2D
# 地图内可交互目标的轻量视觉标记,由 InteractionManager 提供世界坐标。
const RING_COLOR: Color = Color(1.0, 1.0, 1.0, 0.94)
const CORE_COLOR: Color = Color(1.0, 1.0, 1.0, 0.58)
const RING_RADIUS: float = 9.0
const RING_WIDTH: float = 2.0
var _points: Array[Vector2] = []
func set_points(points: Array[Vector2]) -> void:
if _points == points:
return
_points = points.duplicate()
queue_redraw()
func _draw() -> void:
for point: Vector2 in _points:
draw_arc(point, RING_RADIUS, 0.0, TAU, 24, RING_COLOR, RING_WIDTH, true)
draw_circle(point, 2.0, CORE_COLOR)

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -6,9 +6,10 @@ runnable=true
advanced_options=false advanced_options=false
dedicated_server=false dedicated_server=false
custom_features="" custom_features=""
export_filter="all_resources" export_filter="scenes"
include_filter="" export_files=PackedStringArray("res://scenes/Maps/cafe_interior.tscn", "res://scenes/Maps/personal_space.tscn", "res://scenes/Maps/square.tscn", "res://scenes/Maps/work_zone.tscn", "res://scenes/characters/cafe_whale_barista_npc.tscn", "res://scenes/characters/crayfish_npc.tscn", "res://scenes/characters/npc.tscn", "res://scenes/characters/player.tscn", "res://scenes/characters/remote_player.tscn", "res://scenes/prefabs/ui/ChatMessage.tscn", "res://scenes/ui/AuthScene.tscn", "res://scenes/ui/CafeCompanionPanel.tscn", "res://scenes/ui/CafeCompanionRecruitmentPanel.tscn", "res://scenes/ui/ChatBubble.tscn", "res://scenes/ui/ChatUI.tscn", "res://scenes/ui/CourseBoardPanel.tscn", "res://scenes/ui/FriendListPanel.tscn", "res://scenes/ui/MapPanel.tscn", "res://scenes/ui/PlayerHud.tscn", "res://scenes/ui/SettingsPanel.tscn", "res://scenes/ui/datawhale_honor_ranking_panel.tscn", "res://scenes/ui/mall/MallPanel.tscn", "res://scenes/ui/notice_dialog.tscn", "res://scenes/ui/welcome_dialog.tscn")
exclude_filter="" include_filter="Config/*.gd,Config/*.json,_Core/*.gd,_Core/chat/*.gd,_Core/interactions/*.gd,_Core/managers/*.gd,_Core/room_decor/*.gd,_Core/security/*.gd,_Core/systems/*.gd,_Core/ui/*.gd,_Core/utils/*.gd,scenes/Maps/*.gd,scenes/characters/*.gd,scenes/prefabs/items/*.gd,scenes/prefabs/ui/*.gd,scenes/ui/*.gd,scenes/ui/mall/*.gd,assets/audio/ui/*,assets/characters/skins/*,assets/maps/personal_space/v1/decor/*,assets/ui/auth/generated/*,assets/ui/auth/redesign/*,assets/ui/auth/registration_choice/redesign/*,assets/ui/auth/v1/*,assets/ui/mall/branding/*,assets/ui/mall/icons/*,assets/ui/mall/icons/processed/*,assets/ui/mall/items/*,assets/ui/mall/skin/*,assets/ui/mall/skin/clean/*,assets/ui/mall/skins/*,assets/ui/settings/*"
exclude_filter="build/*,tools/*,docs/*,scripts/*,assets/ui/auth/registration_choice/preview/*,assets/ui/auth/registration_choice/redesign_preview/*"
export_path="build/web/index.html" export_path="build/web/index.html"
patches=PackedStringArray() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
@@ -50,9 +51,10 @@ runnable=false
advanced_options=false advanced_options=false
dedicated_server=false dedicated_server=false
custom_features="" custom_features=""
export_filter="all_resources" export_filter="scenes"
include_filter="" export_files=PackedStringArray("res://scenes/Maps/cafe_interior.tscn", "res://scenes/Maps/personal_space.tscn", "res://scenes/Maps/square.tscn", "res://scenes/Maps/work_zone.tscn", "res://scenes/characters/cafe_whale_barista_npc.tscn", "res://scenes/characters/crayfish_npc.tscn", "res://scenes/characters/npc.tscn", "res://scenes/characters/player.tscn", "res://scenes/characters/remote_player.tscn", "res://scenes/prefabs/ui/ChatMessage.tscn", "res://scenes/ui/AuthScene.tscn", "res://scenes/ui/CafeCompanionPanel.tscn", "res://scenes/ui/CafeCompanionRecruitmentPanel.tscn", "res://scenes/ui/ChatBubble.tscn", "res://scenes/ui/ChatUI.tscn", "res://scenes/ui/CourseBoardPanel.tscn", "res://scenes/ui/FriendListPanel.tscn", "res://scenes/ui/MapPanel.tscn", "res://scenes/ui/PlayerHud.tscn", "res://scenes/ui/SettingsPanel.tscn", "res://scenes/ui/datawhale_honor_ranking_panel.tscn", "res://scenes/ui/mall/MallPanel.tscn", "res://scenes/ui/notice_dialog.tscn", "res://scenes/ui/welcome_dialog.tscn")
exclude_filter="" include_filter="Config/*.gd,Config/*.json,_Core/*.gd,_Core/chat/*.gd,_Core/interactions/*.gd,_Core/managers/*.gd,_Core/room_decor/*.gd,_Core/security/*.gd,_Core/systems/*.gd,_Core/ui/*.gd,_Core/utils/*.gd,scenes/Maps/*.gd,scenes/characters/*.gd,scenes/prefabs/items/*.gd,scenes/prefabs/ui/*.gd,scenes/ui/*.gd,scenes/ui/mall/*.gd,assets/audio/ui/*,assets/characters/skins/*,assets/maps/personal_space/v1/decor/*,assets/ui/auth/generated/*,assets/ui/auth/redesign/*,assets/ui/auth/registration_choice/redesign/*,assets/ui/auth/v1/*,assets/ui/mall/branding/*,assets/ui/mall/icons/*,assets/ui/mall/icons/processed/*,assets/ui/mall/items/*,assets/ui/mall/skin/*,assets/ui/mall/skin/clean/*,assets/ui/mall/skins/*,assets/ui/settings/*"
exclude_filter="build/*,tools/*,docs/*,scripts/*,assets/ui/auth/registration_choice/preview/*,assets/ui/auth/registration_choice/redesign_preview/*"
export_path="build/linux/WhaleTown-V2.x86_64" export_path="build/linux/WhaleTown-V2.x86_64"
patches=PackedStringArray() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""

View File

@@ -21,6 +21,7 @@ config/icon="res://icon.svg"
[autoload] [autoload]
UiEscapeManager="*res://_Core/managers/UiEscapeManager.gd"
SceneManager="*res://_Core/managers/SceneManager.gd" SceneManager="*res://_Core/managers/SceneManager.gd"
EventSystem="*res://_Core/systems/EventSystem.gd" EventSystem="*res://_Core/systems/EventSystem.gd"
ApiClient="*res://_Core/managers/ApiClient.gd" ApiClient="*res://_Core/managers/ApiClient.gd"
@@ -32,6 +33,12 @@ CafeCompanionManager="*res://_Core/managers/CafeCompanionManager.gd"
AppearanceManager="*res://_Core/managers/AppearanceManager.gd" AppearanceManager="*res://_Core/managers/AppearanceManager.gd"
SettingsManager="*res://_Core/managers/SettingsManager.gd" SettingsManager="*res://_Core/managers/SettingsManager.gd"
NotificationSoundManager="*res://_Core/managers/NotificationSoundManager.gd" NotificationSoundManager="*res://_Core/managers/NotificationSoundManager.gd"
SocialManager="*res://_Core/managers/SocialManager.gd"
InteractionManager="*res://_Core/managers/InteractionManager.gd"
TaskManager="*res://_Core/managers/TaskManager.gd"
TaskBookPanel="*res://scenes/ui/TaskBookPanel.gd"
TestLabManager="*res://_Core/managers/TestLabManager.gd"
AdminTestLabPanel="*res://scenes/ui/AdminTestLabPanel.gd"
[display] [display]
@@ -71,11 +78,6 @@ interact={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null) "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
] ]
} }
friend_request={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":102,"location":0,"echo":false,"script":null)
]
}
[rendering] [rendering]

View File

@@ -45,6 +45,22 @@ func _ready() -> void:
_connect_exit_area() _connect_exit_area()
_connect_recruitment_area() _connect_recruitment_area()
_connect_cafe_companion_events() _connect_cafe_companion_events()
_register_interactables()
_discover_destination()
_report_task_map_visit()
func _report_task_map_visit() -> void:
var taskManager := get_node_or_null("/root/TaskManager")
if taskManager != null and taskManager.has_method("report_activity"):
taskManager.call("report_activity", "map_visited", "whale_cafe")
func _discover_destination() -> void:
var destination_id := SceneManager.get_next_destination_id()
if destination_id.is_empty():
destination_id = "cafe_entrance"
var socialManager := get_node_or_null("/root/SocialManager")
if socialManager != null and socialManager.has_method("discover_destination"):
socialManager.call("discover_destination", destination_id)
func _exit_tree() -> void: func _exit_tree() -> void:
var eventSystem := get_node_or_null("/root/EventSystem") var eventSystem := get_node_or_null("/root/EventSystem")
@@ -56,6 +72,8 @@ func _align_service_occupants() -> void:
cafeWhaleBaristaNpc.global_position = serviceIdlePoint01.global_position cafeWhaleBaristaNpc.global_position = serviceIdlePoint01.global_position
func _apply_spawn_point() -> void: func _apply_spawn_point() -> void:
if player.has_scene_position_override:
return
var spawnName: String = SceneManager.get_next_spawn_name() var spawnName: String = SceneManager.get_next_spawn_name()
var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn" var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn"
var marker := $Markers.get_node_or_null(markerName) as Marker2D var marker := $Markers.get_node_or_null(markerName) as Marker2D
@@ -75,8 +93,7 @@ func _configure_camera() -> void:
playerCamera.limit_smoothed = true playerCamera.limit_smoothed = true
func _connect_exit_area() -> void: func _connect_exit_area() -> void:
if not exitToWorkZoneArea.body_entered.is_connected(_on_exit_area_body_entered): exitToWorkZoneArea.collision_mask = 0
exitToWorkZoneArea.body_entered.connect(_on_exit_area_body_entered)
func _connect_recruitment_area() -> void: func _connect_recruitment_area() -> void:
cafeRecruitmentLogoArea.input_pickable = true cafeRecruitmentLogoArea.input_pickable = true
@@ -91,8 +108,30 @@ func _connect_cafe_companion_events() -> void:
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_AGENT_REGISTERED, _on_cafe_companion_agent_registered, self) eventSystem.call("connect_event", EventNames.CAFE_COMPANION_AGENT_REGISTERED, _on_cafe_companion_agent_registered, self)
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_EMPLOYMENT_RESIGNED, _on_cafe_companion_employment_resigned, self) eventSystem.call("connect_event", EventNames.CAFE_COMPANION_EMPLOYMENT_RESIGNED, _on_cafe_companion_employment_resigned, self)
func _register_interactables() -> void:
var exit_interactable: InteractableComponent = InteractableComponent.new()
exit_interactable.interaction_id = "cafe_exit"
exit_interactable.interaction_title = "离开咖啡馆"
exit_interactable.interaction_priority = 20
exit_interactable.interaction_distance = 160.0
exit_interactable.activation_method = &"_leave_to_work_zone"
exit_interactable.anchor_path = NodePath("InteractionAreas/ExitToWorkZoneArea")
add_child(exit_interactable)
var recruitment_interactable: InteractableComponent = InteractableComponent.new()
recruitment_interactable.interaction_id = "cafe_recruitment"
recruitment_interactable.interaction_title = "登记咖啡店陪伴机器人"
recruitment_interactable.interaction_priority = 30
recruitment_interactable.interaction_distance = 150.0
recruitment_interactable.activation_method = &"_try_emit_recruitment_selected"
recruitment_interactable.anchor_path = NodePath("InteractionAreas/CafeRecruitmentLogoArea")
add_child(recruitment_interactable)
func _on_exit_area_body_entered(body: Node2D) -> void: func _on_exit_area_body_entered(body: Node2D) -> void:
if _isChangingScene or body != player: return
func _leave_to_work_zone() -> void:
if _isChangingScene:
return return
_isChangingScene = true _isChangingScene = true
SceneManager.set_next_scene_position(WORK_ZONE_CAFE_RETURN_POSITION) SceneManager.set_next_scene_position(WORK_ZONE_CAFE_RETURN_POSITION)

View File

@@ -56,19 +56,10 @@ func _ready() -> void:
call_deferred("_send_world_ready") call_deferred("_send_world_ready")
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
if Input.is_action_just_pressed("interact"): return
_try_start_private_chat()
if Input.is_action_just_pressed("friend_request"):
_try_request_friend()
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("interact"): return
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: func _exit_tree() -> void:
var eventSystem := _get_event_system() var eventSystem := _get_event_system()
@@ -117,7 +108,7 @@ func _is_text_input_focused() -> bool:
return false return false
func _on_interact_pressed(_data: Dictionary = {}) -> void: func _on_interact_pressed(_data: Dictionary = {}) -> void:
_try_start_private_chat() return
func _try_start_private_chat() -> bool: func _try_start_private_chat() -> bool:
if _is_text_input_focused(): if _is_text_input_focused():

File diff suppressed because it is too large Load Diff

View File

@@ -10,19 +10,33 @@ extends Area2D
@export var targetSceneName: String = "" @export var targetSceneName: String = ""
@export var targetSpawnName: String = "" @export var targetSpawnName: String = ""
@export var targetPosition: Vector2 = Vector2.ZERO @export var targetPosition: Vector2 = Vector2.ZERO
@export var interactionTitle: String = "进入"
@export var interactionDistance: float = 160.0
func _ready() -> void: func _ready() -> void:
body_entered.connect(_on_body_entered) var label: String = interactionTitle.strip_edges()
if label.is_empty():
label = "进入 %s" % targetSceneName
var interactable: InteractableComponent = InteractableComponent.new()
interactable.interaction_id = "portal:%s" % str(get_path())
interactable.interaction_title = label
interactable.interaction_priority = 20
interactable.interaction_distance = interactionDistance
interactable.activation_method = &"_change_scene"
add_child(interactable)
func _on_body_entered(body: Node2D) -> void: func _on_body_entered(body: Node2D) -> void:
if not (body is PlayerController): return
return
_change_scene()
func _change_scene() -> void: func _change_scene() -> void:
if targetSceneName.is_empty(): if targetSceneName.is_empty():
push_warning("ScenePortal: targetSceneName is empty.") push_warning("ScenePortal: targetSceneName is empty.")
return return
if targetSceneName == "personal_space":
SceneManager.clear_room_visit_context()
elif targetSceneName == "square" and SceneManager.has_room_visit_context():
SceneManager.return_from_room_visit()
return
if not targetSpawnName.is_empty(): if not targetSpawnName.is_empty():
SceneManager.set_next_spawn_name(targetSpawnName) SceneManager.set_next_spawn_name(targetSpawnName)

View File

@@ -12,6 +12,8 @@ const CAMERA_LIMIT_LEFT: int = -1280
const CAMERA_LIMIT_TOP: int = -960 const CAMERA_LIMIT_TOP: int = -960
const CAMERA_LIMIT_RIGHT: int = 1280 const CAMERA_LIMIT_RIGHT: int = 1280
const CAMERA_LIMIT_BOTTOM: int = 960 const CAMERA_LIMIT_BOTTOM: int = 960
const WELCOME_DIALOG_SCENE: PackedScene = preload("res://scenes/ui/welcome_dialog.tscn")
const WELCOME_DIALOG_NAME: String = "WelcomeDialog"
@onready var player: PlayerController = $YSortWorld/Characters/Players/Player @onready var player: PlayerController = $YSortWorld/Characters/Players/Player
@onready var playerCamera: Camera2D = $YSortWorld/Characters/Players/Player/Camera2D @onready var playerCamera: Camera2D = $YSortWorld/Characters/Players/Player/Camera2D
@@ -20,8 +22,41 @@ const CAMERA_LIMIT_BOTTOM: int = 960
func _ready() -> void: func _ready() -> void:
_apply_spawn_point() _apply_spawn_point()
_configure_camera() _configure_camera()
_discover_destination()
call_deferred("_show_registration_welcome")
_report_task_map_visit()
func _report_task_map_visit() -> void:
var taskManager := get_node_or_null("/root/TaskManager")
if taskManager != null and taskManager.has_method("report_activity"):
taskManager.call("report_activity", "map_visited", "square")
func _show_registration_welcome() -> void:
var auth_manager: Node = get_node_or_null("/root/AuthManager")
if auth_manager == null or not auth_manager.has_method("consume_registration_welcome"):
return
if not bool(auth_manager.call("consume_registration_welcome")):
return
var root: Window = get_tree().root
if root.has_node(WELCOME_DIALOG_NAME):
return
var dialog: CanvasLayer = WELCOME_DIALOG_SCENE.instantiate() as CanvasLayer
if dialog == null:
return
dialog.name = WELCOME_DIALOG_NAME
root.add_child(dialog)
func _discover_destination() -> void:
var destination_id := SceneManager.get_next_destination_id()
if destination_id.is_empty():
destination_id = "square_center"
var socialManager := get_node_or_null("/root/SocialManager")
if socialManager != null and socialManager.has_method("discover_destination"):
socialManager.call("discover_destination", destination_id)
func _apply_spawn_point() -> void: func _apply_spawn_point() -> void:
if player.has_scene_position_override:
return
var spawnName: String = SceneManager.get_next_spawn_name() var spawnName: String = SceneManager.get_next_spawn_name()
var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn" var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn"
var marker := $Markers.get_node_or_null(markerName) as Marker2D var marker := $Markers.get_node_or_null(markerName) as Marker2D

View File

@@ -38,12 +38,29 @@ func _ready() -> void:
_ensure_mall_entrance_area() _ensure_mall_entrance_area()
EventSystem.connect_event(EventNames.OBJECT_INTERACTED, _on_object_interacted, self) EventSystem.connect_event(EventNames.OBJECT_INTERACTED, _on_object_interacted, self)
EventSystem.connect_event(EventNames.MALL_CLOSED, _on_mall_closed, self) EventSystem.connect_event(EventNames.MALL_CLOSED, _on_mall_closed, self)
_discover_destination()
_report_task_map_visit()
func _report_task_map_visit() -> void:
var taskManager := get_node_or_null("/root/TaskManager")
if taskManager != null and taskManager.has_method("report_activity"):
taskManager.call("report_activity", "map_visited", "work_zone")
func _discover_destination() -> void:
var destination_id := SceneManager.get_next_destination_id()
if destination_id.is_empty():
destination_id = "work_entrance"
var socialManager := get_node_or_null("/root/SocialManager")
if socialManager != null and socialManager.has_method("discover_destination"):
socialManager.call("discover_destination", destination_id)
func _exit_tree() -> void: func _exit_tree() -> void:
EventSystem.disconnect_event(EventNames.OBJECT_INTERACTED, _on_object_interacted, self) EventSystem.disconnect_event(EventNames.OBJECT_INTERACTED, _on_object_interacted, self)
EventSystem.disconnect_event(EventNames.MALL_CLOSED, _on_mall_closed, self) EventSystem.disconnect_event(EventNames.MALL_CLOSED, _on_mall_closed, self)
func _apply_spawn_point() -> void: func _apply_spawn_point() -> void:
if player.has_scene_position_override:
return
var spawnName: String = SceneManager.get_next_spawn_name() var spawnName: String = SceneManager.get_next_spawn_name()
var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn" var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn"
var marker := $Markers.get_node_or_null(markerName) as Marker2D var marker := $Markers.get_node_or_null(markerName) as Marker2D
@@ -112,19 +129,23 @@ func _ensure_mall_entrance_area() -> void:
func _setup_mall_entrance_trigger(entrance: Area2D) -> void: func _setup_mall_entrance_trigger(entrance: Area2D) -> void:
_mallEntranceArea = entrance _mallEntranceArea = entrance
_mallEntranceArea.collision_mask = PLAYER_COLLISION_LAYER _mallEntranceArea.collision_mask = 0
if not _mallEntranceArea.body_entered.is_connected(_on_mall_entrance_body_entered):
_mallEntranceArea.body_entered.connect(_on_mall_entrance_body_entered)
func _on_object_interacted(data: Dictionary) -> void: func _on_object_interacted(data: Dictionary) -> void:
var buildingId := str(data.get("buildingId", "")) var buildingId := str(data.get("buildingId", ""))
if buildingId.is_empty(): if buildingId.is_empty():
return return
if buildingId == "whale_super_mall": if buildingId == "whale_super_mall":
_enter_mall()
return return
if buildingId == "virtual_whale_recruitment_board": if buildingId == "virtual_whale_recruitment_board":
_open_course_board() _open_course_board()
return return
if buildingId == "whale_job_center":
var taskBook := get_node_or_null("/root/TaskBookPanel")
if taskBook != null and taskBook.has_method("show_panel"):
taskBook.call("show_panel")
return
if not [ if not [
"whale_job_center", "whale_job_center",
"ai_service_station", "ai_service_station",
@@ -142,9 +163,7 @@ func _open_course_board() -> void:
_courseBoardPanel.call("show_panel") _courseBoardPanel.call("show_panel")
func _on_mall_entrance_body_entered(body: Node2D) -> void: func _on_mall_entrance_body_entered(body: Node2D) -> void:
if body != player or not _mallCanEnter: return
return
_enter_mall()
func _enter_mall() -> void: func _enter_mall() -> void:
if _isInsideMall: if _isInsideMall:

View File

@@ -12,10 +12,23 @@ const INTERACTION_COLLISION_LAYER: int = 2
@export var buildingId: String = "" @export var buildingId: String = ""
@export var buildingTitle: String = "" @export var buildingTitle: String = ""
@export var buildingRole: String = "" @export var buildingRole: String = ""
@export var interactionDistance: float = 150.0
func _ready() -> void: func _ready() -> void:
collision_layer = INTERACTION_COLLISION_LAYER collision_layer = INTERACTION_COLLISION_LAYER
collision_mask = 0 collision_mask = 0
var interactable: InteractableComponent = InteractableComponent.new()
interactable.interaction_distance = interactionDistance
add_child(interactable)
func is_interaction_active(_component: InteractableComponent) -> bool:
return not buildingId.strip_edges().is_empty()
func build_interaction_actions(_component: InteractableComponent, _player: Node2D) -> Array[InteractionAction]:
var actions: Array[InteractionAction] = []
var title: String = buildingTitle if not buildingTitle.is_empty() else "设施"
actions.append(InteractionAction.create("building:%s" % buildingId, "使用 %s" % title, 30, Callable(self, "interact")))
return actions
func interact() -> void: func interact() -> void:
var payload := { var payload := {

View File

@@ -1,4 +1,4 @@
[gd_scene load_steps=12 format=4] [gd_scene load_steps=13 format=4]
[ext_resource type="Script" path="res://scenes/Maps/PersonalSpace.gd" id="1_personal_space"] [ext_resource type="Script" path="res://scenes/Maps/PersonalSpace.gd" id="1_personal_space"]
[ext_resource type="Texture2D" path="res://assets/maps/personal_space/v1/base/personal_room_25d_sidewalls_wider_not_longer_v1.png" id="2_room_base"] [ext_resource type="Texture2D" path="res://assets/maps/personal_space/v1/base/personal_room_25d_sidewalls_wider_not_longer_v1.png" id="2_room_base"]
@@ -8,6 +8,7 @@
[ext_resource type="PackedScene" path="res://scenes/ui/PlayerHud.tscn" id="6_playerhud"] [ext_resource type="PackedScene" path="res://scenes/ui/PlayerHud.tscn" id="6_playerhud"]
[ext_resource type="PackedScene" path="res://scenes/ui/FriendListPanel.tscn" id="7_friendpanel"] [ext_resource type="PackedScene" path="res://scenes/ui/FriendListPanel.tscn" id="7_friendpanel"]
[ext_resource type="PackedScene" path="res://scenes/ui/SettingsPanel.tscn" id="8_settingspanel"] [ext_resource type="PackedScene" path="res://scenes/ui/SettingsPanel.tscn" id="8_settingspanel"]
[ext_resource type="PackedScene" path="res://scenes/ui/MapPanel.tscn" id="9_mappanel"]
[sub_resource type="RectangleShape2D" id="Shape_TopWall"] [sub_resource type="RectangleShape2D" id="Shape_TopWall"]
size = Vector2(882, 76) size = Vector2(882, 76)
@@ -35,6 +36,8 @@ layer = 10
[node name="SettingsPanel" parent="UILayer" instance=ExtResource("8_settingspanel")] [node name="SettingsPanel" parent="UILayer" instance=ExtResource("8_settingspanel")]
[node name="MapPanel" parent="UILayer" instance=ExtResource("9_mappanel")]
[node name="StageBackdrop" type="ColorRect" parent="."] [node name="StageBackdrop" type="ColorRect" parent="."]
z_index = -200 z_index = -200
offset_left = -2000.0 offset_left = -2000.0

View File

@@ -117,9 +117,6 @@ size = Vector2(180, 160)
[sub_resource type="RectangleShape2D" id="Shape_NoticeBoard"] [sub_resource type="RectangleShape2D" id="Shape_NoticeBoard"]
size = Vector2(123, 88) size = Vector2(123, 88)
[sub_resource type="RectangleShape2D" id="Shape_WelcomeBoard"]
size = Vector2(97.5, 95)
[node name="Square" type="Node2D" unique_id=1071968945] [node name="Square" type="Node2D" unique_id=1071968945]
script = ExtResource("43_square") script = ExtResource("43_square")
@@ -1237,15 +1234,6 @@ script = ExtResource("28_notice")
position = Vector2(-59.5, 19) position = Vector2(-59.5, 19)
shape = SubResource("Shape_NoticeBoard") shape = SubResource("Shape_NoticeBoard")
[node name="WelcomeBoardArea" type="Area2D" parent="InteractionAreas" unique_id=1460340742]
position = Vector2(707.5, 575)
collision_layer = 0
collision_mask = 0
[node name="CollisionShape2D" type="CollisionShape2D" parent="InteractionAreas/WelcomeBoardArea" unique_id=123982451]
position = Vector2(-159.25, -10.5)
shape = SubResource("Shape_WelcomeBoard")
[node name="BlockoutDebug" type="Node2D" parent="." unique_id=932099081] [node name="BlockoutDebug" type="Node2D" parent="." unique_id=932099081]
visible = false visible = false

View File

@@ -19,9 +19,22 @@ class_name CafeCompanionTarget
var _lastClickMsec: int = 0 var _lastClickMsec: int = 0
func _ready() -> void: func _ready() -> void:
var interactable: InteractableComponent = InteractableComponent.new()
interactable.interaction_distance = 160.0
add_child(interactable)
input_pickable = true input_pickable = true
set_process_unhandled_input(true) set_process_unhandled_input(true)
func is_interaction_active(_component: InteractableComponent) -> bool:
return not servicePointId.strip_edges().is_empty() and not companionId.strip_edges().is_empty()
func build_interaction_actions(_component: InteractableComponent, _player: Node2D) -> Array[InteractionAction]:
var actions: Array[InteractionAction] = []
var display_name: String = _resolved_persona_name()
var action_id: String = "cafe_companion:%s" % servicePointId
actions.append(InteractionAction.create(action_id, "%s 交流" % display_name, 45, Callable(self, "_try_emit_target_selected")))
return actions
func _input_event(_viewport: Viewport, event: InputEvent, _shapeIdx: int) -> void: func _input_event(_viewport: Viewport, event: InputEvent, _shapeIdx: int) -> void:
if not (event is InputEventMouseButton): if not (event is InputEventMouseButton):
return return

View File

@@ -34,12 +34,21 @@ const NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12
@export_multiline var dialogue: String = "欢迎来到WhaleTown我是镇长范鲸晶" @export_multiline var dialogue: String = "欢迎来到WhaleTown我是镇长范鲸晶"
@export var showNameplate: bool = false @export var showNameplate: bool = false
@export var nameplateOffsetY: float = -112.0 @export var nameplateOffsetY: float = -112.0
@export var interactionDistance: float = 150.0
@onready var animation_player: AnimationPlayer = $AnimationPlayer @onready var animation_player: AnimationPlayer = $AnimationPlayer
var _nameplate: Label var _nameplate: Label
func _ready() -> void: func _ready() -> void:
if get_node_or_null("CafeCompanionTarget") == null:
var interactable: InteractableComponent = InteractableComponent.new()
interactable.interaction_id = "npc:%s" % str(get_path())
interactable.interaction_title = "%s 交谈" % npcName
interactable.interaction_priority = 40
interactable.interaction_distance = interactionDistance
interactable.activation_method = &"interact"
add_child(interactable)
# 播放场景里配置好的待机动画,让不同 NPC 可以复用同一个控制器。 # 播放场景里配置好的待机动画,让不同 NPC 可以复用同一个控制器。
if animation_player.has_animation("idle"): if animation_player.has_animation("idle"):
animation_player.play("idle") animation_player.play("idle")
@@ -55,6 +64,9 @@ func _physics_process(_delta: float) -> void:
# 处理玩家交互,展示气泡并向全局事件系统广播。 # 处理玩家交互,展示气泡并向全局事件系统广播。
func interact() -> void: func interact() -> void:
var taskManager := get_node_or_null("/root/TaskManager")
if taskManager != null and taskManager.has_method("report_activity"):
taskManager.call("report_activity", "facility_interacted", "npc")
show_bubble(dialogue) show_bubble(dialogue)
var eventSystem: Node = get_node_or_null("/root/EventSystem") var eventSystem: Node = get_node_or_null("/root/EventSystem")
if eventSystem != null: if eventSystem != null:
@@ -63,7 +75,7 @@ func interact() -> void:
"npc_name": npcName, "npc_name": npcName,
"dialogue": dialogue "dialogue": dialogue
}) })
interaction_happened.emit(dialogue) interaction_happened.emit(dialogue)
# 在 NPC 头顶生成一次性聊天气泡。 # 在 NPC 头顶生成一次性聊天气泡。
# #

View File

@@ -25,8 +25,11 @@ const DIRECTION_ROWS: Dictionary = {
var lastDirection: String = "down" var lastDirection: String = "down"
var _nameLabel: Label var _nameLabel: Label
var _movementLocked: bool = false var _movementLocked: bool = false
var has_scene_position_override: bool = false
var _discoveryElapsed: float = 0.0
func _ready() -> void: func _ready() -> void:
add_to_group("whaletown_local_player")
_reset_movement_input_state() _reset_movement_input_state()
_apply_current_appearance() _apply_current_appearance()
_subscribe_to_appearance_events() _subscribe_to_appearance_events()
@@ -71,28 +74,40 @@ func _release_movement_actions() -> void:
func _check_spawn_position() -> void: func _check_spawn_position() -> void:
var spawnPos: Variant = SceneManager.get_next_scene_position() var spawnPos: Variant = SceneManager.get_next_scene_position()
if spawnPos != null: if spawnPos is Vector2:
global_position = spawnPos global_position = spawnPos
_update_world_sort_z() has_scene_position_override = true
_update_world_sort_z()
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
_handle_movement(delta) _handle_movement(delta)
_update_world_sort_z() _update_world_sort_z()
_handle_interaction() _handle_interaction()
_report_nearby_discoveries(delta)
func _handle_interaction() -> void: func _handle_interaction() -> void:
if _is_text_input_focused(): # 统一交互由 /root/InteractionManager 收集附近候选并处理 E 键。
return
func _report_nearby_discoveries(delta: float) -> void:
_discoveryElapsed += delta
if _discoveryElapsed < 0.35:
return return
if Input.is_action_just_pressed("interact"): _discoveryElapsed = 0.0
EventSystem.emit_event(EventNames.INTERACT_PRESSED, { var scene := get_tree().current_scene
"player": self, if scene == null:
"position": global_position, return
"direction": lastDirection var map_id: String = str({
}) "Square": "whale_port",
if ray_cast.is_colliding(): "WorkZone": "work_zone",
var collider := ray_cast.get_collider() "CafeInterior": "whale_cafe",
if collider and collider.has_method("interact"): "PersonalSpace": "personal_space",
collider.interact() }.get(str(scene.name), ""))
if map_id.is_empty():
return
var social_manager := get_node_or_null("/root/SocialManager")
if social_manager != null and social_manager.has_method("discover_nearby_destinations"):
social_manager.call("discover_nearby_destinations", map_id, global_position)
func _handle_movement(_delta: float) -> void: func _handle_movement(_delta: float) -> void:
if _movementLocked: if _movementLocked:
@@ -111,10 +126,7 @@ func _handle_movement(_delta: float) -> void:
return return
# 获取移动向量 (参考 docs/02-开发规范/输入映射配置.md) # 获取移动向量 (参考 docs/02-开发规范/输入映射配置.md)
var direction := Input.get_vector( var direction := _movement_direction()
"move_left", "move_right",
"move_up", "move_down"
)
# 应用移动 # 应用移动
if direction != Vector2.ZERO: if direction != Vector2.ZERO:
@@ -133,6 +145,16 @@ func _handle_movement(_delta: float) -> void:
"position": global_position "position": global_position
}) })
func _movement_direction() -> Vector2:
var interactionManager := get_node_or_null("/root/InteractionManager")
if interactionManager != null and interactionManager.has_method("is_selection_active") and bool(interactionManager.call("is_selection_active")):
# 有交互候选时方向键用于选择,保留 WASD 移动。
return Vector2(
(-1.0 if Input.is_key_pressed(KEY_A) else 0.0) + (1.0 if Input.is_key_pressed(KEY_D) else 0.0),
(-1.0 if Input.is_key_pressed(KEY_W) else 0.0) + (1.0 if Input.is_key_pressed(KEY_S) else 0.0)
).normalized()
return Input.get_vector("move_left", "move_right", "move_up", "move_down")
func _update_animation_state(direction: Vector2) -> void: func _update_animation_state(direction: Vector2) -> void:
if not animation_player: if not animation_player:
return return

View File

@@ -36,8 +36,12 @@ const DIRECTION_ROWS: Dictionary = {
@onready var sprite: Sprite2D = $Sprite2D @onready var sprite: Sprite2D = $Sprite2D
var _nameLabel: Label var _nameLabel: Label
var _cafeCompanionTarget: CafeCompanionTarget var _cafeCompanionTarget: CafeCompanionTarget
var _interactable: InteractableComponent
func _ready() -> void: func _ready() -> void:
_interactable = InteractableComponent.new()
_interactable.interaction_distance = 160.0
add_child(_interactable)
# 初始化时确保无物理处理 # 初始化时确保无物理处理
set_physics_process(false) set_physics_process(false)
# 初始位置设为当前位置 # 初始位置设为当前位置
@@ -52,6 +56,32 @@ func _ready() -> void:
if has_node("CollisionShape2D"): if has_node("CollisionShape2D"):
$CollisionShape2D.disabled = true $CollisionShape2D.disabled = true
func is_interaction_active(_component: InteractableComponent) -> bool:
return not userId.strip_edges().is_empty()
func build_interaction_actions(_component: InteractableComponent, _player: Node2D) -> Array[InteractionAction]:
var actions: Array[InteractionAction] = []
var display_name := username if not username.strip_edges().is_empty() else "玩家"
actions.append(InteractionAction.create("player_card:%s" % userId, "查看 %s 的社区名片" % display_name, 60, Callable(self, "_show_community_profile")))
actions.append(InteractionAction.create("player_dm:%s" % userId, "私聊 %s" % display_name, 61, Callable(self, "_open_private_chat")))
actions.append(InteractionAction.create("player_friend:%s" % userId, "申请添加 %s 为好友" % display_name, 62, Callable(self, "_request_friend")))
return actions
func _show_community_profile() -> void:
var socialManager := get_node_or_null("/root/SocialManager")
if socialManager != null and socialManager.has_method("show_profile"):
socialManager.call("show_profile", userId)
func _open_private_chat() -> void:
var socialManager := get_node_or_null("/root/SocialManager")
if socialManager != null and socialManager.has_method("open_private_chat"):
socialManager.call("open_private_chat", userId, username)
func _request_friend() -> void:
var socialManager := get_node_or_null("/root/SocialManager")
if socialManager != null and socialManager.has_method("request_friend"):
socialManager.call("request_friend", userId, username)
func _exit_tree() -> void: func _exit_tree() -> void:
var eventSystem := get_node_or_null("/root/EventSystem") var eventSystem := get_node_or_null("/root/EventSystem")
if eventSystem != null: if eventSystem != null:

View File

@@ -8,6 +8,13 @@ const INTERACTION_COLLISION_LAYER: int = 2
func _ready() -> void: func _ready() -> void:
collision_layer = INTERACTION_COLLISION_LAYER collision_layer = INTERACTION_COLLISION_LAYER
collision_mask = 0 collision_mask = 0
var interactable: InteractableComponent = InteractableComponent.new()
interactable.interaction_id = "honor_board:%s" % str(get_path())
interactable.interaction_title = "查看荣誉榜"
interactable.interaction_priority = 35
interactable.interaction_distance = 150.0
interactable.activation_method = &"interact"
add_child(interactable)
func interact() -> void: func interact() -> void:
var root: Window = get_tree().root var root: Window = get_tree().root

View File

@@ -8,8 +8,19 @@ const INTERACTION_COLLISION_LAYER: int = 2
func _ready() -> void: func _ready() -> void:
collision_layer = INTERACTION_COLLISION_LAYER collision_layer = INTERACTION_COLLISION_LAYER
collision_mask = 0 collision_mask = 0
var interactable: InteractableComponent = InteractableComponent.new()
interactable.interaction_id = "notice_board:%s" % str(get_path())
interactable.interaction_title = "查看公告栏"
interactable.interaction_priority = 35
interactable.interaction_distance = 150.0
interactable.activation_method = &"interact"
add_child(interactable)
func interact() -> void: func interact() -> void:
var taskManager := get_node_or_null("/root/TaskManager")
if taskManager != null and taskManager.has_method("report_activity"):
taskManager.call("report_activity", "notice_viewed")
taskManager.call("report_activity", "facility_interacted", "notice_board")
var root: Window = get_tree().root var root: Window = get_tree().root
if root.has_node(NOTICE_DIALOG_NAME): if root.has_node(NOTICE_DIALOG_NAME):
return return

View File

@@ -8,8 +8,18 @@ const INTERACTION_COLLISION_LAYER: int = 2
func _ready() -> void: func _ready() -> void:
collision_layer = INTERACTION_COLLISION_LAYER collision_layer = INTERACTION_COLLISION_LAYER
collision_mask = 0 collision_mask = 0
var interactable: InteractableComponent = InteractableComponent.new()
interactable.interaction_id = "welcome_board:%s" % str(get_path())
interactable.interaction_title = "查看新人引导"
interactable.interaction_priority = 35
interactable.interaction_distance = 150.0
interactable.activation_method = &"interact"
add_child(interactable)
func interact() -> void: func interact() -> void:
var taskManager := get_node_or_null("/root/TaskManager")
if taskManager != null and taskManager.has_method("report_activity"):
taskManager.call("report_activity", "facility_interacted", "welcome_board")
var root: Window = get_tree().root var root: Window = get_tree().root
if root.has_node(WELCOME_DIALOG_NAME): if root.has_node(WELCOME_DIALOG_NAME):
return return

View File

@@ -0,0 +1,685 @@
extends CanvasLayer
# 仅 role=9 可打开的游戏内测试实验室。服务端会再次验证管理员身份。
const PANEL_SIZE: Vector2 = Vector2(1180, 820)
const MAP_OPTIONS: Array[Dictionary] = [
{"id": "whale_port", "label": "鲸鱼港广场"},
{"id": "work_zone", "label": "打工区"},
{"id": "whale_cafe", "label": "鲸鱼咖啡馆"},
]
const SKIN_OPTIONS: Array[Dictionary] = [
{"id": "classic_whale", "label": "经典鲸鱼"},
{"id": "human_whale_directional_v2_8x4", "label": "海风行者"},
{"id": "girl_sailor_turnaround_v2_8x4", "label": "海风少女"},
{"id": "panda_hero_8x4", "label": "熊猫侠"},
{"id": "ordinary_man_male_8x4", "label": "普通人(男)"},
]
const POLICY_OPTIONS: Array[Dictionary] = [
{"id": "public", "label": "公开访问"},
{"id": "friends", "label": "仅好友"},
{"id": "closed", "label": "关闭访问"},
]
const SCOPE_OPTIONS: Array[Dictionary] = [
{"id": "local", "label": "当前地图公共消息"},
{"id": "global", "label": "全服测试消息"},
{"id": "private", "label": "私聊选定玩家"},
]
const TEXT_COLOR: Color = Color(0.84, 0.92, 0.96)
const MUTED_COLOR: Color = Color(0.53, 0.68, 0.75)
const SURFACE_COLOR: Color = Color(0.035, 0.105, 0.16, 0.98)
const RAISED_COLOR: Color = Color(0.065, 0.16, 0.23, 1.0)
const ACCENT_COLOR: Color = Color(0.20, 0.80, 0.86, 1.0)
const DANGER_COLOR: Color = Color(0.82, 0.29, 0.25, 1.0)
var _root: Control
var _panel: PanelContainer
var _status_label: Label
var _notice_label: Label
var _actor_list: VBoxContainer
var _selected_label: Label
var _target_select: OptionButton
var _create_nickname: LineEdit
var _create_map: OptionButton
var _create_x: SpinBox
var _create_y: SpinBox
var _create_skin: OptionButton
var _actor_online: CheckButton
var _actor_map: OptionButton
var _actor_x: SpinBox
var _actor_y: SpinBox
var _actor_skin: OptionButton
var _policy_select: OptionButton
var _message_scope: OptionButton
var _message_input: TextEdit
var _selected_actor_id: String = ""
var _is_open: bool = false
var _clear_confirm_deadline_ms: int = 0
func _ready() -> void:
layer = 48
process_mode = Node.PROCESS_MODE_ALWAYS
add_to_group("whaletown_escape_dismissible")
_build_ui()
_connect_services()
func _exit_tree() -> void:
var event_system := get_node_or_null("/root/EventSystem")
if event_system != null:
event_system.call("disconnect_event", EventNames.HUD_ADMIN_TEST_LAB_REQUESTED, _on_open_requested, self)
var manager := get_node_or_null("/root/TestLabManager")
if manager != null:
_disconnect_signal(manager, "status_changed", Callable(self, "_on_status_changed"))
_disconnect_signal(manager, "status_failed", Callable(self, "_on_status_failed"))
_disconnect_signal(manager, "operation_completed", Callable(self, "_on_operation_completed"))
func show_panel() -> void:
if not _is_admin():
hide_panel()
return
_is_open = true
_root.visible = true
_set_notice("正在读取测试实验室…", MUTED_COLOR)
var manager := _manager()
if manager != null:
if manager.has_method("is_loaded") and bool(manager.call("is_loaded")):
var status_variant: Variant = manager.call("get_status")
if status_variant is Dictionary:
_on_status_changed(status_variant as Dictionary)
if manager.has_method("refresh_status"):
manager.call("refresh_status")
func hide_panel() -> void:
_is_open = false
if is_instance_valid(_root):
_root.visible = false
func is_escape_dismissible() -> bool:
return _is_open
func get_escape_priority() -> int:
return 850
func request_escape_close() -> void:
hide_panel()
func _input(event: InputEvent) -> void:
if not _is_open or get_viewport().is_input_handled():
return
if event.is_action_pressed("ui_cancel"):
hide_panel()
get_viewport().set_input_as_handled()
func _connect_services() -> void:
var event_system := get_node_or_null("/root/EventSystem")
if event_system != null:
event_system.call("connect_event", EventNames.HUD_ADMIN_TEST_LAB_REQUESTED, _on_open_requested, self)
var manager := _manager()
if manager == null:
return
_connect_signal(manager, "status_changed", Callable(self, "_on_status_changed"))
_connect_signal(manager, "status_failed", Callable(self, "_on_status_failed"))
_connect_signal(manager, "operation_completed", Callable(self, "_on_operation_completed"))
func _build_ui() -> void:
_root = Control.new()
_root.name = "AdminTestLabRoot"
_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_root.visible = false
add_child(_root)
var dim := ColorRect.new()
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
dim.color = Color(0.005, 0.02, 0.04, 0.78)
dim.mouse_filter = Control.MOUSE_FILTER_STOP
dim.gui_input.connect(func(event: InputEvent) -> void:
if event is InputEventMouseButton and (event as InputEventMouseButton).pressed:
hide_panel()
)
_root.add_child(dim)
_panel = PanelContainer.new()
_panel.custom_minimum_size = PANEL_SIZE
_panel.set_anchors_preset(Control.PRESET_CENTER)
_panel.offset_left = -PANEL_SIZE.x * 0.5
_panel.offset_top = -PANEL_SIZE.y * 0.5
_panel.offset_right = PANEL_SIZE.x * 0.5
_panel.offset_bottom = PANEL_SIZE.y * 0.5
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
_panel.add_theme_stylebox_override("panel", _style(SURFACE_COLOR, 24, Color(0.18, 0.48, 0.58, 0.86), 1))
_root.add_child(_panel)
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 24)
margin.add_theme_constant_override("margin_top", 20)
margin.add_theme_constant_override("margin_right", 24)
margin.add_theme_constant_override("margin_bottom", 20)
_panel.add_child(margin)
var layout := VBoxContainer.new()
layout.add_theme_constant_override("separation", 14)
margin.add_child(layout)
layout.add_child(_build_header())
_notice_label = Label.new()
_notice_label.visible = false
_notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_notice_label.add_theme_font_size_override("font_size", 14)
layout.add_child(_notice_label)
var body := HSplitContainer.new()
body.size_flags_vertical = Control.SIZE_EXPAND_FILL
body.split_offset = 358
layout.add_child(body)
body.add_child(_build_roster_column())
body.add_child(_build_control_column())
var footer := HBoxContainer.new()
footer.add_theme_constant_override("separation", 10)
layout.add_child(footer)
var hint := Label.new()
hint.text = "仅开发/测试环境可用;所有动作由服务端再次校验。"
hint.size_flags_horizontal = Control.SIZE_EXPAND_FILL
hint.add_theme_color_override("font_color", MUTED_COLOR)
hint.add_theme_font_size_override("font_size", 13)
footer.add_child(hint)
footer.add_child(_button("刷新", ACCENT_COLOR, _on_refresh_pressed, Vector2(86, 38)))
footer.add_child(_button("清空实验室", DANGER_COLOR, _on_clear_pressed, Vector2(126, 38)))
var esc := Label.new()
esc.text = "[Esc] 关闭"
esc.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
esc.add_theme_color_override("font_color", MUTED_COLOR)
esc.add_theme_font_size_override("font_size", 13)
footer.add_child(esc)
func _build_header() -> Control:
var header := PanelContainer.new()
header.custom_minimum_size = Vector2(0, 92)
header.add_theme_stylebox_override("panel", _style(Color(0.045, 0.19, 0.27, 1.0), 16, ACCENT_COLOR.darkened(0.30), 1))
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 20)
margin.add_theme_constant_override("margin_top", 12)
margin.add_theme_constant_override("margin_right", 14)
margin.add_theme_constant_override("margin_bottom", 12)
header.add_child(margin)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
margin.add_child(row)
var title_box := VBoxContainer.new()
title_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(title_box)
var eyebrow := Label.new()
eyebrow.text = "管理员 · 多人互动验证"
eyebrow.add_theme_color_override("font_color", Color(0.51, 0.86, 0.89, 1.0))
eyebrow.add_theme_font_size_override("font_size", 13)
title_box.add_child(eyebrow)
var title := Label.new()
title.text = "测试实验室"
title.add_theme_color_override("font_color", Color.WHITE)
title.add_theme_font_size_override("font_size", 30)
title_box.add_child(title)
_status_label = Label.new()
_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_status_label.add_theme_color_override("font_color", TEXT_COLOR)
_status_label.add_theme_font_size_override("font_size", 14)
row.add_child(_status_label)
row.add_child(_button("关闭", Color(0.20, 0.36, 0.44, 1.0), hide_panel, Vector2(78, 38)))
return header
func _build_roster_column() -> Control:
var column := VBoxContainer.new()
column.custom_minimum_size = Vector2(336, 0)
column.add_theme_constant_override("separation", 12)
var create_title := Label.new()
create_title.text = "投放假人"
create_title.add_theme_color_override("font_color", TEXT_COLOR)
create_title.add_theme_font_size_override("font_size", 20)
column.add_child(create_title)
var create_box := PanelContainer.new()
create_box.add_theme_stylebox_override("panel", _style(RAISED_COLOR, 14, Color(0.16, 0.36, 0.46, 0.72), 1))
column.add_child(create_box)
var create_margin := MarginContainer.new()
create_margin.add_theme_constant_override("margin_left", 14)
create_margin.add_theme_constant_override("margin_top", 14)
create_margin.add_theme_constant_override("margin_right", 14)
create_margin.add_theme_constant_override("margin_bottom", 14)
create_box.add_child(create_margin)
var create_form := VBoxContainer.new()
create_form.add_theme_constant_override("separation", 8)
create_margin.add_child(create_form)
_create_nickname = _line_edit("显示昵称,例如 社交测试鲸")
create_form.add_child(_field("昵称", _create_nickname))
_create_map = _option(MAP_OPTIONS)
create_form.add_child(_field("地图", _create_map))
var create_position := HBoxContainer.new()
create_position.add_theme_constant_override("separation", 8)
_create_x = _spin(1280)
_create_y = _spin(960)
create_position.add_child(_field("X", _create_x))
create_position.add_child(_field("Y", _create_y))
create_form.add_child(create_position)
_create_skin = _option(SKIN_OPTIONS)
create_form.add_child(_field("皮肤", _create_skin))
create_form.add_child(_button("创建并上线", ACCENT_COLOR, _on_create_pressed, Vector2(0, 40)))
var roster_title := Label.new()
roster_title.text = "假人编队"
roster_title.add_theme_color_override("font_color", TEXT_COLOR)
roster_title.add_theme_font_size_override("font_size", 20)
column.add_child(roster_title)
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
column.add_child(scroll)
_actor_list = VBoxContainer.new()
_actor_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_actor_list.add_theme_constant_override("separation", 7)
scroll.add_child(_actor_list)
return column
func _build_control_column() -> Control:
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 12)
var title_row := HBoxContainer.new()
column.add_child(title_row)
_selected_label = Label.new()
_selected_label.text = "选择一名假人开始控制"
_selected_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_selected_label.add_theme_color_override("font_color", TEXT_COLOR)
_selected_label.add_theme_font_size_override("font_size", 20)
title_row.add_child(_selected_label)
title_row.add_child(_button("删除假人", DANGER_COLOR, _on_delete_selected_pressed, Vector2(96, 34)))
var controls_scroll := ScrollContainer.new()
controls_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
controls_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
column.add_child(controls_scroll)
var controls := VBoxContainer.new()
controls.size_flags_horizontal = Control.SIZE_EXPAND_FILL
controls.add_theme_constant_override("separation", 12)
controls_scroll.add_child(controls)
controls.add_child(_build_presence_section())
controls.add_child(_build_room_section())
controls.add_child(_build_social_section())
return column
func _build_presence_section() -> Control:
var section := _section("在线与位置")
var content := section.get_node("Margin/Layout/Content") as VBoxContainer
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
_actor_online = CheckButton.new()
_actor_online.text = "在线"
_actor_online.button_pressed = true
_actor_online.add_theme_color_override("font_color", TEXT_COLOR)
row.add_child(_actor_online)
row.add_spacer(false)
row.add_child(_button("同步状态", ACCENT_COLOR, _on_update_pressed, Vector2(104, 36)))
content.add_child(row)
_actor_map = _option(MAP_OPTIONS)
content.add_child(_field("地图", _actor_map))
var position := HBoxContainer.new()
position.add_theme_constant_override("separation", 10)
_actor_x = _spin(1280)
_actor_y = _spin(960)
position.add_child(_field("X", _actor_x))
position.add_child(_field("Y", _actor_y))
content.add_child(position)
_actor_skin = _option(SKIN_OPTIONS)
content.add_child(_field("皮肤", _actor_skin))
return section
func _build_room_section() -> Control:
var section := _section("房间访问")
var content := section.get_node("Margin/Layout/Content") as VBoxContainer
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
_policy_select = _option(POLICY_OPTIONS)
_policy_select.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(_policy_select)
row.add_child(_button("保存权限", Color(0.20, 0.42, 0.55, 1.0), _on_policy_pressed, Vector2(102, 36)))
content.add_child(row)
return section
func _build_social_section() -> Control:
var section := _section("社交注入")
var content := section.get_node("Margin/Layout/Content") as VBoxContainer
var target_hint := Label.new()
target_hint.text = "目标真实在线玩家(私聊、好友和拉黑操作必选)"
target_hint.add_theme_color_override("font_color", MUTED_COLOR)
target_hint.add_theme_font_size_override("font_size", 13)
content.add_child(target_hint)
_target_select = OptionButton.new()
_target_select.custom_minimum_size = Vector2(0, 36)
_target_select.add_item("暂无在线真实玩家")
_target_select.add_theme_stylebox_override("normal", _style(Color(0.03, 0.10, 0.15, 1.0), 10, Color(0.20, 0.42, 0.52, 0.8), 1))
_target_select.add_theme_color_override("font_color", TEXT_COLOR)
content.add_child(_target_select)
_message_scope = _option(SCOPE_OPTIONS)
content.add_child(_field("消息范围", _message_scope))
_message_input = TextEdit.new()
_message_input.custom_minimum_size = Vector2(0, 78)
_message_input.placeholder_text = "测试消息不会同步到 Zulip、任务或鲸币。"
_message_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
_message_input.add_theme_color_override("font_color", TEXT_COLOR)
_message_input.add_theme_color_override("font_placeholder_color", MUTED_COLOR)
_message_input.add_theme_stylebox_override("normal", _style(Color(0.025, 0.085, 0.13, 1.0), 10, Color(0.20, 0.42, 0.52, 0.8), 1))
content.add_child(_message_input)
content.add_child(_button("发送测试消息", ACCENT_COLOR, _on_message_pressed, Vector2(0, 38)))
var social_row := HBoxContainer.new()
social_row.add_theme_constant_override("separation", 8)
social_row.add_child(_button("发起好友申请", Color(0.20, 0.42, 0.55, 1.0), func() -> void: _on_social_pressed("friend_request"), Vector2(116, 36)))
social_row.add_child(_button("接受", Color(0.20, 0.42, 0.55, 1.0), func() -> void: _on_social_pressed("friend_accept"), Vector2(72, 36)))
social_row.add_child(_button("拒绝", Color(0.20, 0.42, 0.55, 1.0), func() -> void: _on_social_pressed("friend_reject"), Vector2(72, 36)))
social_row.add_child(_button("拉黑", DANGER_COLOR, func() -> void: _on_social_pressed("block"), Vector2(72, 36)))
content.add_child(social_row)
return section
func _section(title_text: String) -> PanelContainer:
var section := PanelContainer.new()
section.add_theme_stylebox_override("panel", _style(RAISED_COLOR, 14, Color(0.16, 0.36, 0.46, 0.72), 1))
var margin := MarginContainer.new()
margin.name = "Margin"
margin.add_theme_constant_override("margin_left", 14)
margin.add_theme_constant_override("margin_top", 12)
margin.add_theme_constant_override("margin_right", 14)
margin.add_theme_constant_override("margin_bottom", 14)
section.add_child(margin)
var layout := VBoxContainer.new()
layout.name = "Layout"
layout.add_theme_constant_override("separation", 9)
margin.add_child(layout)
var title := Label.new()
title.text = title_text
title.add_theme_color_override("font_color", Color(0.68, 0.91, 0.94, 1.0))
title.add_theme_font_size_override("font_size", 16)
layout.add_child(title)
var content := VBoxContainer.new()
content.name = "Content"
content.add_theme_constant_override("separation", 7)
layout.add_child(content)
return section
func _field(label_text: String, control: Control) -> VBoxContainer:
var field := VBoxContainer.new()
field.size_flags_horizontal = Control.SIZE_EXPAND_FILL
field.add_theme_constant_override("separation", 3)
var label := Label.new()
label.text = label_text
label.add_theme_color_override("font_color", MUTED_COLOR)
label.add_theme_font_size_override("font_size", 12)
field.add_child(label)
control.size_flags_horizontal = Control.SIZE_EXPAND_FILL
field.add_child(control)
return field
func _line_edit(placeholder: String) -> LineEdit:
var input := LineEdit.new()
input.placeholder_text = placeholder
input.custom_minimum_size = Vector2(0, 36)
input.add_theme_color_override("font_color", TEXT_COLOR)
input.add_theme_color_override("font_placeholder_color", MUTED_COLOR)
input.add_theme_stylebox_override("normal", _style(Color(0.025, 0.085, 0.13, 1.0), 10, Color(0.20, 0.42, 0.52, 0.8), 1))
input.add_theme_stylebox_override("focus", _style(Color(0.025, 0.085, 0.13, 1.0), 10, ACCENT_COLOR, 1))
return input
func _spin(initial_value: int) -> SpinBox:
var spin := SpinBox.new()
spin.min_value = -8192
spin.max_value = 8192
spin.step = 1
spin.value = initial_value
spin.custom_minimum_size = Vector2(0, 36)
spin.add_theme_color_override("font_color", TEXT_COLOR)
return spin
func _option(items: Array[Dictionary]) -> OptionButton:
var select := OptionButton.new()
select.custom_minimum_size = Vector2(0, 36)
select.add_theme_stylebox_override("normal", _style(Color(0.025, 0.085, 0.13, 1.0), 10, Color(0.20, 0.42, 0.52, 0.8), 1))
select.add_theme_color_override("font_color", TEXT_COLOR)
for item: Dictionary in items:
select.add_item(str(item.get("label", "")))
select.set_item_metadata(select.item_count - 1, str(item.get("id", "")))
return select
func _button(text_value: String, color: Color, callback: Callable, size: Vector2) -> Button:
var button := Button.new()
button.text = text_value
button.custom_minimum_size = size
button.focus_mode = Control.FOCUS_NONE
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
button.add_theme_color_override("font_color", Color.WHITE)
button.add_theme_color_override("font_hover_color", Color.WHITE)
button.add_theme_stylebox_override("normal", _style(color, 10))
button.add_theme_stylebox_override("hover", _style(color.lightened(0.12), 10))
button.add_theme_stylebox_override("pressed", _style(color.darkened(0.12), 10))
button.pressed.connect(callback)
return button
func _style(color: Color, radius: int, border_color: Color = Color.TRANSPARENT, border_width: int = 0) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = color
style.corner_radius_top_left = radius
style.corner_radius_top_right = radius
style.corner_radius_bottom_left = radius
style.corner_radius_bottom_right = radius
if border_width > 0:
style.border_width_left = border_width
style.border_width_top = border_width
style.border_width_right = border_width
style.border_width_bottom = border_width
style.border_color = border_color
return style
func _on_open_requested(_data: Variant = null) -> void:
show_panel()
func _on_refresh_pressed() -> void:
var manager := _manager()
if manager != null:
manager.call("refresh_status")
func _on_create_pressed() -> void:
if _create_nickname.text.strip_edges().is_empty():
_set_notice("请输入测试假人的显示昵称", DANGER_COLOR.lightened(0.18))
return
var manager := _manager()
if manager != null:
manager.call("create_actor", _create_nickname.text, _selected_option(_create_map), int(_create_x.value), int(_create_y.value), _selected_option(_create_skin))
func _on_update_pressed() -> void:
if not _ensure_selected_actor():
return
var manager := _manager()
if manager != null:
manager.call("update_actor", _selected_actor_id, _actor_online.button_pressed, _selected_option(_actor_map), int(_actor_x.value), int(_actor_y.value), _selected_option(_actor_skin))
func _on_policy_pressed() -> void:
if not _ensure_selected_actor():
return
var manager := _manager()
if manager != null:
manager.call("set_room_policy", _selected_actor_id, _selected_option(_policy_select))
func _on_message_pressed() -> void:
if not _ensure_selected_actor():
return
var content := _message_input.text.strip_edges()
if content.is_empty():
_set_notice("请输入测试消息", DANGER_COLOR.lightened(0.18))
return
var scope := _selected_option(_message_scope)
var target_id := _selected_target_id()
if scope == "private" and target_id.is_empty():
_set_notice("私聊需要先选择在线真实玩家", DANGER_COLOR.lightened(0.18))
return
var manager := _manager()
if manager != null:
manager.call("send_message", _selected_actor_id, scope, content, target_id)
func _on_social_pressed(action: String) -> void:
if not _ensure_selected_actor():
return
var target_id := _selected_target_id()
if target_id.is_empty():
_set_notice("请先选择在线真实玩家", DANGER_COLOR.lightened(0.18))
return
var manager := _manager()
if manager != null:
manager.call("social_action", _selected_actor_id, action, target_id)
func _on_clear_pressed() -> void:
var now_ms: int = Time.get_ticks_msec()
if now_ms > _clear_confirm_deadline_ms:
_clear_confirm_deadline_ms = now_ms + 4000
_set_notice("再次点击“清空实验室”以确认;这会删除全部测试账号和关联测试数据。", DANGER_COLOR.lightened(0.18))
return
_clear_confirm_deadline_ms = 0
var manager := _manager()
if manager != null:
manager.call("clear_all")
func _on_delete_selected_pressed() -> void:
if not _ensure_selected_actor():
return
var manager := _manager()
if manager != null:
manager.call("delete_actor", _selected_actor_id)
func _on_status_changed(status: Dictionary) -> void:
if not _is_open:
return
var enabled: bool = bool(status.get("enabled", false))
var environment: String = str(status.get("environment", "未知"))
var actors_variant: Variant = status.get("actors", [])
var actor_count: int = (actors_variant as Array).size() if actors_variant is Array else 0
var max_actors: int = int(status.get("maxActors", 20))
_status_label.text = "%s · 假人 %d/%d" % [environment, actor_count, max_actors]
_set_notice("实验室已启用" if enabled else "测试实验室未启用(仅 development/test 且 TEST_LAB_ENABLED=true", ACCENT_COLOR if enabled else DANGER_COLOR.lightened(0.18))
_render_actor_list(actors_variant)
_render_online_players(status.get("onlinePlayers", []))
func _on_status_failed(message: String) -> void:
if _is_open:
_set_notice(message, DANGER_COLOR.lightened(0.18))
func _on_operation_completed(message: String) -> void:
if _is_open:
_message_input.text = ""
_set_notice(message, ACCENT_COLOR)
func _render_actor_list(actors_variant: Variant) -> void:
_clear_children(_actor_list)
if not (actors_variant is Array) or (actors_variant as Array).is_empty():
var empty := Label.new()
empty.text = "尚未投放测试假人"
empty.add_theme_color_override("font_color", MUTED_COLOR)
empty.add_theme_font_size_override("font_size", 14)
_actor_list.add_child(empty)
_selected_actor_id = ""
_selected_label.text = "选择一名假人开始控制"
return
var found_selected: bool = false
for actor_variant: Variant in actors_variant as Array:
if not (actor_variant is Dictionary):
continue
var actor: Dictionary = actor_variant as Dictionary
var actor_id: String = str(actor.get("userId", ""))
if actor_id == _selected_actor_id:
found_selected = true
_actor_list.add_child(_actor_row(actor))
if not found_selected:
var first_actor_variant: Variant = (actors_variant as Array)[0]
if first_actor_variant is Dictionary:
_select_actor(first_actor_variant as Dictionary)
func _actor_row(actor: Dictionary) -> Button:
var actor_id: String = str(actor.get("userId", ""))
var nickname: String = str(actor.get("nickname", actor.get("username", "测试假人")))
var map_id: String = str(actor.get("mapId", ""))
var online: bool = bool(actor.get("online", false))
var selected: bool = actor_id == _selected_actor_id
var button := Button.new()
button.custom_minimum_size = Vector2(0, 64)
button.text = "%s · %s\n%s · %d, %d" % ["" if online else "", nickname, map_id, int(actor.get("x", 0)), int(actor.get("y", 0))]
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.focus_mode = Control.FOCUS_NONE
button.add_theme_font_size_override("font_size", 14)
button.add_theme_color_override("font_color", TEXT_COLOR)
button.add_theme_stylebox_override("normal", _style(Color(0.08, 0.23, 0.30, 1.0) if selected else Color(0.05, 0.14, 0.20, 1.0), 10, ACCENT_COLOR if selected else Color(0.16, 0.36, 0.46, 0.52), 1))
button.add_theme_stylebox_override("hover", _style(Color(0.10, 0.29, 0.37, 1.0), 10, ACCENT_COLOR.darkened(0.10), 1))
button.pressed.connect(func() -> void: _select_actor(actor))
return button
func _select_actor(actor: Dictionary) -> void:
_selected_actor_id = str(actor.get("userId", ""))
var nickname: String = str(actor.get("nickname", actor.get("username", "测试假人")))
_selected_label.text = "控制 · %s" % nickname
_actor_online.button_pressed = bool(actor.get("online", false))
_actor_x.value = int(actor.get("x", 1280))
_actor_y.value = int(actor.get("y", 960))
_select_option(_actor_map, str(actor.get("mapId", "whale_port")))
_select_option(_actor_skin, str(actor.get("skinId", "classic_whale")))
_select_option(_policy_select, str(actor.get("roomVisitPolicy", "public")))
var manager := _manager()
if manager != null and manager.has_method("get_status"):
var status_variant: Variant = manager.call("get_status")
if status_variant is Dictionary:
_render_actor_list((status_variant as Dictionary).get("actors", []))
func _render_online_players(players_variant: Variant) -> void:
_target_select.clear()
if not (players_variant is Array) or (players_variant as Array).is_empty():
_target_select.add_item("暂无在线真实玩家")
_target_select.set_item_metadata(0, "")
return
for player_variant: Variant in players_variant as Array:
if not (player_variant is Dictionary):
continue
var player: Dictionary = player_variant as Dictionary
var user_id: String = str(player.get("userId", ""))
var username: String = str(player.get("username", user_id))
var map_id: String = str(player.get("mapId", ""))
_target_select.add_item("%s · %s" % [username, map_id])
_target_select.set_item_metadata(_target_select.item_count - 1, user_id)
func _selected_option(select: OptionButton) -> String:
if select.selected < 0:
return ""
return str(select.get_item_metadata(select.selected))
func _selected_target_id() -> String:
return _selected_option(_target_select)
func _select_option(select: OptionButton, value: String) -> void:
for index: int in range(select.item_count):
if str(select.get_item_metadata(index)) == value:
select.select(index)
return
if select.item_count > 0:
select.select(0)
func _ensure_selected_actor() -> bool:
if not _selected_actor_id.is_empty():
return true
_set_notice("请先从左侧选择测试假人", DANGER_COLOR.lightened(0.18))
return false
func _set_notice(message: String, color: Color) -> void:
_notice_label.visible = not message.strip_edges().is_empty()
_notice_label.text = message
_notice_label.add_theme_color_override("font_color", color)
func _clear_children(node: Node) -> void:
for child: Node in node.get_children():
child.queue_free()
func _is_admin() -> bool:
var manager := _manager()
return manager != null and manager.has_method("is_admin") and bool(manager.call("is_admin"))
func _manager() -> Node:
return get_node_or_null("/root/TestLabManager")
func _connect_signal(source: Node, signal_name: StringName, callback: Callable) -> void:
if source.has_signal(signal_name) and not source.is_connected(signal_name, callback):
source.connect(signal_name, callback)
func _disconnect_signal(source: Node, signal_name: StringName, callback: Callable) -> void:
if source.has_signal(signal_name) and source.is_connected(signal_name, callback):
source.disconnect(signal_name, callback)

View File

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

View File

@@ -25,7 +25,6 @@ const REGISTRATION_CHOICE_SPRITESHEET_GRID_BOX_PATH: String = REGISTRATION_CHOIC
const REGISTRATION_CHOICE_REFERENCE_UPLOAD_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/reference_upload_box.png" const REGISTRATION_CHOICE_REFERENCE_UPLOAD_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/reference_upload_box.png"
const REGISTRATION_CHOICE_IMAGE_PLACEHOLDER_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/image_placeholder_box.png" const REGISTRATION_CHOICE_IMAGE_PLACEHOLDER_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/image_placeholder_box.png"
const UI_FONT_PATH: String = "res://assets/fonts/msyh.ttc" const UI_FONT_PATH: String = "res://assets/fonts/msyh.ttc"
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const SKIN_GENERATION_CREATE_ENDPOINT: String = "/api/skin-generation/jobs" const SKIN_GENERATION_CREATE_ENDPOINT: String = "/api/skin-generation/jobs"
const SKIN_GENERATION_POLL_ENDPOINT_TEMPLATE: String = "/api/skin-generation/jobs/%s" const SKIN_GENERATION_POLL_ENDPOINT_TEMPLATE: String = "/api/skin-generation/jobs/%s"
const SKIN_GENERATION_POLL_INTERVAL: float = 2.0 const SKIN_GENERATION_POLL_INTERVAL: float = 2.0
@@ -94,8 +93,6 @@ var _brand_font: SystemFont
var _choice_font: SystemFont var _choice_font: SystemFont
var _resuming_cached_session: bool = false var _resuming_cached_session: bool = false
var _cached_resume_start_generation: int = 0 var _cached_resume_start_generation: int = 0
var _skin_generation_create_request: HTTPRequest
var _skin_generation_poll_request: HTTPRequest
var _skin_generation_active: bool = false var _skin_generation_active: bool = false
var _skin_generation_job_id: String = "" var _skin_generation_job_id: String = ""
var _skin_generation_poll_elapsed: float = 0.0 var _skin_generation_poll_elapsed: float = 0.0
@@ -680,12 +677,12 @@ func _registration_texture_rect(nodeName: String, texturePath: String, rect: Rec
return textureRect return textureRect
func _ready() -> void: func _ready() -> void:
add_to_group("whaletown_escape_dismissible")
_auth_manager = get_node_or_null("/root/AuthManager") _auth_manager = get_node_or_null("/root/AuthManager")
_chat_manager = get_node_or_null("/root/ChatManager") _chat_manager = get_node_or_null("/root/ChatManager")
_scene_manager = get_node_or_null("/root/SceneManager") _scene_manager = get_node_or_null("/root/SceneManager")
_appearance_manager = get_node_or_null("/root/AppearanceManager") _appearance_manager = get_node_or_null("/root/AppearanceManager")
_setup_skin_generation_requests()
_connect_signals() _connect_signals()
_refresh_appearance_ui() _refresh_appearance_ui()
_show_login() _show_login()
@@ -726,19 +723,6 @@ func _connect_signals() -> void:
_auth_manager.connect("profile_update_failed", _on_profile_update_failed) _auth_manager.connect("profile_update_failed", _on_profile_update_failed)
_auth_manager.connect("auth_state_changed", _on_auth_state_changed) _auth_manager.connect("auth_state_changed", _on_auth_state_changed)
func _setup_skin_generation_requests() -> void:
_skin_generation_create_request = HTTPRequest.new()
_skin_generation_create_request.name = "SkinGenerationCreateRequest"
_skin_generation_create_request.timeout = SKIN_GENERATION_REQUEST_TIMEOUT
_skin_generation_create_request.request_completed.connect(_on_skin_generation_create_completed)
add_child(_skin_generation_create_request)
_skin_generation_poll_request = HTTPRequest.new()
_skin_generation_poll_request.name = "SkinGenerationPollRequest"
_skin_generation_poll_request.timeout = SKIN_GENERATION_REQUEST_TIMEOUT
_skin_generation_poll_request.request_completed.connect(_on_skin_generation_poll_completed)
add_child(_skin_generation_poll_request)
func _process(delta: float) -> void: func _process(delta: float) -> void:
if not _skin_generation_active or _skin_generation_job_id.is_empty(): if not _skin_generation_active or _skin_generation_job_id.is_empty():
return return
@@ -748,10 +732,6 @@ func _process(delta: float) -> void:
_poll_skin_generation_job() _poll_skin_generation_job()
func _exit_tree() -> void: func _exit_tree() -> void:
if is_instance_valid(_skin_generation_create_request):
_skin_generation_create_request.cancel_request()
if is_instance_valid(_skin_generation_poll_request):
_skin_generation_poll_request.cancel_request()
_skin_generation_active = false _skin_generation_active = false
_skin_generation_job_id = "" _skin_generation_job_id = ""
@@ -1401,23 +1381,27 @@ func _on_generate_skin_pressed() -> void:
"source_image_base64": _image_to_png_base64(sourceImage), "source_image_base64": _image_to_png_base64(sourceImage),
"source_mime_type": "image/png", "source_mime_type": "image/png",
} }
var err := _skin_generation_create_request.request( var api_client := get_node_or_null("/root/ApiClient")
"%s%s" % [NetworkConfig.get_api_base_url(), SKIN_GENERATION_CREATE_ENDPOINT], if api_client == null or not api_client.has_method("request_json"):
_auth_json_headers(), _skin_generation_active = false
_set_skin_generation_controls_enabled(true)
_set_workshop_generation_status("角色生成服务不可用")
return
api_client.call(
"request_json",
SKIN_GENERATION_CREATE_ENDPOINT,
payload,
_on_skin_generation_create_completed,
HTTPClient.METHOD_POST, HTTPClient.METHOD_POST,
JSON.stringify(payload) true,
SKIN_GENERATION_REQUEST_TIMEOUT
) )
if err != OK:
_skin_generation_active = false
_set_skin_generation_controls_enabled(true)
_set_workshop_generation_status("角色生成请求发送失败:%s" % error_string(err))
func _on_skin_generation_create_completed(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void: func _on_skin_generation_create_completed(success: bool, response: Dictionary, error_info: Dictionary) -> void:
var parsed := _parse_skin_generation_response(result, responseCode, body) if not success:
if not bool(parsed.get("ok", false)):
_skin_generation_active = false _skin_generation_active = false
_set_skin_generation_controls_enabled(true) _set_skin_generation_controls_enabled(true)
var errorMessage := str(parsed.get("message", "角色生成任务创建失败")) var errorMessage := str(error_info.get("message", "角色生成任务创建失败"))
if errorMessage.contains("已经使用过注册角色生成机会") or errorMessage.contains("没有可用的注册角色生成机会"): if errorMessage.contains("已经使用过注册角色生成机会") or errorMessage.contains("没有可用的注册角色生成机会"):
_awaiting_registration_skin_generation = false _awaiting_registration_skin_generation = false
_set_workshop_generation_status("该账号已完成注册角色生成,正在进入小镇...") _set_workshop_generation_status("该账号已完成注册角色生成,正在进入小镇...")
@@ -1426,7 +1410,8 @@ func _on_skin_generation_create_completed(result: int, responseCode: int, _heade
_set_workshop_generation_status(errorMessage) _set_workshop_generation_status(errorMessage)
return return
var data: Dictionary = parsed.get("data", {}) var data_variant: Variant = response.get("data", {})
var data: Dictionary = data_variant as Dictionary if data_variant is Dictionary else {}
_skin_generation_job_id = str(data.get("job_id", "")).strip_edges() _skin_generation_job_id = str(data.get("job_id", "")).strip_edges()
if _skin_generation_job_id.is_empty(): if _skin_generation_job_id.is_empty():
_skin_generation_active = false _skin_generation_active = false
@@ -1442,26 +1427,33 @@ func _poll_skin_generation_job() -> void:
_skin_generation_poll_in_flight = true _skin_generation_poll_in_flight = true
var endpoint := SKIN_GENERATION_POLL_ENDPOINT_TEMPLATE % _skin_generation_job_id var endpoint := SKIN_GENERATION_POLL_ENDPOINT_TEMPLATE % _skin_generation_job_id
var err := _skin_generation_poll_request.request( var api_client := get_node_or_null("/root/ApiClient")
"%s%s" % [NetworkConfig.get_api_base_url(), endpoint], if api_client == null or not api_client.has_method("request_json"):
_auth_json_headers(),
HTTPClient.METHOD_GET
)
if err != OK:
_skin_generation_poll_in_flight = false _skin_generation_poll_in_flight = false
_set_workshop_generation_status("查询生成状态失败:%s" % error_string(err)) _set_workshop_generation_status("角色生成服务不可用")
return
api_client.call(
"request_json",
endpoint,
{},
_on_skin_generation_poll_completed,
HTTPClient.METHOD_GET,
true,
SKIN_GENERATION_REQUEST_TIMEOUT
)
func _on_skin_generation_poll_completed(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void: func _on_skin_generation_poll_completed(success: bool, response: Dictionary, error_info: Dictionary) -> void:
_skin_generation_poll_in_flight = false _skin_generation_poll_in_flight = false
var parsed := _parse_skin_generation_response(result, responseCode, body) if not success:
if not bool(parsed.get("ok", false)): var response_code := int(error_info.get("response_code", 0))
if responseCode == 401 or responseCode == 403 or responseCode == 404: if response_code == 401 or response_code == 403 or response_code == 404:
_skin_generation_active = false _skin_generation_active = false
_set_skin_generation_controls_enabled(true) _set_skin_generation_controls_enabled(true)
_set_workshop_generation_status(str(parsed.get("message", "查询生成状态失败"))) _set_workshop_generation_status(str(error_info.get("message", "查询生成状态失败")))
return return
var data: Dictionary = parsed.get("data", {}) var data_variant: Variant = response.get("data", {})
var data: Dictionary = data_variant as Dictionary if data_variant is Dictionary else {}
var status := str(data.get("status", "")).strip_edges() var status := str(data.get("status", "")).strip_edges()
var message := str(data.get("message", "")).strip_edges() var message := str(data.get("message", "")).strip_edges()
if not message.is_empty(): if not message.is_empty():
@@ -1547,73 +1539,6 @@ func _set_skin_generation_controls_enabled(enabled: bool) -> void:
if is_instance_valid(workshop_generate_button): if is_instance_valid(workshop_generate_button):
workshop_generate_button.disabled = not enabled workshop_generate_button.disabled = not enabled
func _json_headers() -> PackedStringArray:
return PackedStringArray([
"Content-Type: application/json",
"Accept: application/json",
])
func _auth_json_headers() -> PackedStringArray:
var headers := _json_headers()
if _auth_manager != null and _auth_manager.has_method("get_access_token"):
var token := str(_auth_manager.call("get_access_token")).strip_edges()
if not token.is_empty():
headers.append("Authorization: Bearer %s" % token)
return headers
func _parse_skin_generation_response(result: int, responseCode: int, body: PackedByteArray) -> Dictionary:
if result != HTTPRequest.RESULT_SUCCESS:
return {
"ok": false,
"message": "网络请求失败:%s" % _http_result_to_string(result),
}
var bodyText := body.get_string_from_utf8()
var json := JSON.new()
var error := json.parse(bodyText)
if error != OK or not (json.data is Dictionary):
return {
"ok": false,
"message": "服务器响应解析失败",
}
var response: Dictionary = json.data as Dictionary
var success := responseCode >= 200 and responseCode < 300 and bool(response.get("success", true))
if not success:
return {
"ok": false,
"message": str(response.get("message", "请求失败")),
"response_code": responseCode,
}
var dataVariant: Variant = response.get("data", response)
if not (dataVariant is Dictionary):
return {
"ok": false,
"message": "服务器响应格式错误",
}
return {
"ok": true,
"data": dataVariant as Dictionary,
}
func _http_result_to_string(result: int) -> String:
match result:
HTTPRequest.RESULT_SUCCESS:
return "SUCCESS"
HTTPRequest.RESULT_TIMEOUT:
return "TIMEOUT"
HTTPRequest.RESULT_CANT_CONNECT:
return "CANT_CONNECT"
HTTPRequest.RESULT_CANT_RESOLVE:
return "CANT_RESOLVE"
HTTPRequest.RESULT_CONNECTION_ERROR:
return "CONNECTION_ERROR"
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
return "TLS_HANDSHAKE_ERROR"
_:
return "UNKNOWN_%d" % result
func _hide_skin_workshop() -> void: func _hide_skin_workshop() -> void:
if _awaiting_registration_skin_generation: if _awaiting_registration_skin_generation:
@@ -1625,6 +1550,15 @@ func _hide_skin_workshop() -> void:
if is_instance_valid(skin_workshop_overlay): if is_instance_valid(skin_workshop_overlay):
skin_workshop_overlay.hide() skin_workshop_overlay.hide()
func is_escape_dismissible() -> bool:
return is_instance_valid(skin_workshop_overlay) and skin_workshop_overlay.visible
func get_escape_priority() -> int:
return 900
func request_escape_close() -> void:
_hide_skin_workshop()
func _on_workshop_source_pressed() -> void: func _on_workshop_source_pressed() -> void:
var err := DisplayServer.file_dialog_show( var err := DisplayServer.file_dialog_show(
"选择角色参考图片", "选择角色参考图片",

View File

@@ -30,6 +30,7 @@ var _resignConfirmButton: Button
var _resignCancelButton: Button var _resignCancelButton: Button
func _ready() -> void: func _ready() -> void:
add_to_group("whaletown_escape_dismissible")
chatFrame.visible = false chatFrame.visible = false
purchaseButton.pressed.connect(_on_purchase_pressed) purchaseButton.pressed.connect(_on_purchase_pressed)
sendButton.pressed.connect(_on_send_pressed) sendButton.pressed.connect(_on_send_pressed)
@@ -238,6 +239,18 @@ func _on_close_pressed() -> void:
_waitingForReply = false _waitingForReply = false
chatInput.release_focus() chatInput.release_focus()
func is_escape_dismissible() -> bool:
return (is_instance_valid(_resignDialog) and _resignDialog.visible) or chatFrame.visible
func get_escape_priority() -> int:
return 850
func request_escape_close() -> void:
if is_instance_valid(_resignDialog) and _resignDialog.visible:
_hide_resign_dialog()
return
_on_close_pressed()
func _confirm_resign() -> void: func _confirm_resign() -> void:
var servicePointId := str(_resignTarget.get("service_point_id", "")).strip_edges() var servicePointId := str(_resignTarget.get("service_point_id", "")).strip_edges()
if servicePointId.is_empty(): if servicePointId.is_empty():

View File

@@ -32,6 +32,7 @@ var _isSubmitting: bool = false
var _isFetchingModels: bool = false var _isFetchingModels: bool = false
func _ready() -> void: func _ready() -> void:
add_to_group("whaletown_escape_dismissible")
recruitmentFrame.visible = false recruitmentFrame.visible = false
_setup_protocol_options() _setup_protocol_options()
_setup_employment_duration_options() _setup_employment_duration_options()
@@ -398,6 +399,15 @@ func _on_close_pressed() -> void:
fetchModelsButton.disabled = false fetchModelsButton.disabled = false
tokenInput.clear() tokenInput.clear()
func is_escape_dismissible() -> bool:
return recruitmentFrame.visible
func get_escape_priority() -> int:
return 900
func request_escape_close() -> void:
_on_close_pressed()
func _set_status(message: String, isError: bool) -> void: func _set_status(message: String, isError: bool) -> void:
statusLabel.text = message statusLabel.text = message
statusLabel.add_theme_color_override("font_color", Color(0.68, 0.22, 0.18, 1) if isError else Color(0.32, 0.42, 0.48, 1)) statusLabel.add_theme_color_override("font_color", Color(0.68, 0.22, 0.18, 1) if isError else Color(0.32, 0.42, 0.48, 1))

View File

@@ -125,6 +125,7 @@ var _send_failure_handled_by_ui: bool = false
# 准备就绪 # 准备就绪
func _ready() -> void: func _ready() -> void:
add_to_group("whaletown_escape_dismissible")
_configure_mouse_focus() _configure_mouse_focus()
# 初始隐藏聊天框 # 初始隐藏聊天框
@@ -182,6 +183,8 @@ func _get_settings_manager() -> Node:
# 处理全局输入 # 处理全局输入
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if get_viewport().is_input_handled():
return
if event is InputEventMouseButton: if event is InputEventMouseButton:
_handle_global_mouse_button_input(event as InputEventMouseButton) _handle_global_mouse_button_input(event as InputEventMouseButton)
return return
@@ -366,6 +369,15 @@ func hide_chat(immediate: bool = false) -> void:
_transition_tween.tween_property(chat_panel, "modulate:a", 0.0, CHAT_TRANSITION_DURATION) _transition_tween.tween_property(chat_panel, "modulate:a", 0.0, CHAT_TRANSITION_DURATION)
_transition_tween.finished.connect(_on_hide_transition_finished) _transition_tween.finished.connect(_on_hide_transition_finished)
func is_escape_dismissible() -> bool:
return _is_chat_visible
func get_escape_priority() -> int:
return 480
func request_escape_close() -> void:
hide_chat()
# 创建隐藏计时器 # 创建隐藏计时器
func _create_hide_timer() -> void: func _create_hide_timer() -> void:
_hide_timer = Timer.new() _hide_timer = Timer.new()

View File

@@ -8,8 +8,8 @@ extends Control
# ============================================================================ # ============================================================================
const MOVEMENT_ACTIONS: Array[String] = ["move_left", "move_right", "move_up", "move_down"] const MOVEMENT_ACTIONS: Array[String] = ["move_left", "move_right", "move_up", "move_down"]
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const API_PATH: String = "/course-resources/datawhale" const API_PATH: String = "/course-resources/datawhale"
const REQUEST_TIMEOUT: float = 18.0
const PANEL_SIZE: Vector2 = Vector2(1500, 1020) const PANEL_SIZE: Vector2 = Vector2(1500, 1020)
const MIN_MARGIN: Vector2 = Vector2(24, 24) const MIN_MARGIN: Vector2 = Vector2(24, 24)
const CARD_SIZE: Vector2 = Vector2(320, 348) const CARD_SIZE: Vector2 = Vector2(320, 348)
@@ -24,11 +24,11 @@ var _overlay: ColorRect
var _panel: Control var _panel: Control
var _grid: GridContainer var _grid: GridContainer
var _statusLabel: Label var _statusLabel: Label
var _request: HTTPRequest
var _isOpen: bool = false var _isOpen: bool = false
var _transitionTween: Tween var _transitionTween: Tween
func _ready() -> void: func _ready() -> void:
add_to_group("whaletown_escape_dismissible")
visible = false visible = false
mouse_filter = Control.MOUSE_FILTER_IGNORE mouse_filter = Control.MOUSE_FILTER_IGNORE
set_process(false) set_process(false)
@@ -39,6 +39,8 @@ func _notification(what: int) -> void:
_positionPanel() _positionPanel()
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if get_viewport().is_input_handled():
return
if not _isOpen: if not _isOpen:
return return
if event is InputEventKey: if event is InputEventKey:
@@ -59,6 +61,9 @@ func show_panel() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP mouse_filter = Control.MOUSE_FILTER_STOP
set_process(true) set_process(true)
_releaseMovementInputState() _releaseMovementInputState()
var taskManager := get_node_or_null("/root/TaskManager")
if taskManager != null and taskManager.has_method("report_activity"):
taskManager.call("report_activity", "course_board_opened")
_fetchCourses() _fetchCourses()
_animatePanel(true) _animatePanel(true)
@@ -72,6 +77,15 @@ func hide_panel() -> void:
func is_panel_open() -> bool: func is_panel_open() -> bool:
return _isOpen return _isOpen
func is_escape_dismissible() -> bool:
return _isOpen
func get_escape_priority() -> int:
return 700
func request_escape_close() -> void:
hide_panel()
func _buildUi() -> void: func _buildUi() -> void:
_overlay = ColorRect.new() _overlay = ColorRect.new()
_overlay.name = "courseBoardDimOverlay" _overlay.name = "courseBoardDimOverlay"
@@ -109,10 +123,6 @@ func _buildUi() -> void:
root.add_child(_buildGridPanel()) root.add_child(_buildGridPanel())
root.add_child(_buildFooter()) root.add_child(_buildFooter())
_request = HTTPRequest.new()
_request.timeout = 18.0
_request.request_completed.connect(_onRequestCompleted)
add_child(_request)
_updatePanelAlpha(0.0, 0.0) _updatePanelAlpha(0.0, 0.0)
func _buildHeader() -> Control: func _buildHeader() -> Control:
@@ -207,23 +217,15 @@ func _buildFooter() -> Control:
func _fetchCourses() -> void: func _fetchCourses() -> void:
_statusLabel.text = "正在同步 Datawhale 课程..." _statusLabel.text = "正在同步 Datawhale 课程..."
_clearChildren(_grid) _clearChildren(_grid)
var err := _request.request("%s%s" % [NetworkConfig.get_api_base_url(), API_PATH]) var api_client := get_node_or_null("/root/ApiClient")
if err != OK: if api_client == null or not api_client.has_method("get_json"):
_statusLabel.text = "课程加载失败:%s" % error_string(err) _statusLabel.text = "课程服务不可用"
func _onRequestCompleted(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300:
_statusLabel.text = "课程加载失败,请稍后再试"
return return
api_client.call("get_json", API_PATH, _onRequestCompleted, false, REQUEST_TIMEOUT)
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) func _onRequestCompleted(success: bool, payload: Dictionary, error_info: Dictionary) -> void:
if not parsed is Dictionary: if not success:
_statusLabel.text = "课程数据解析失败" _statusLabel.text = str(error_info.get("message", "课程加载失败,请稍后再试"))
return
var payload: Dictionary = parsed as Dictionary
if not bool(payload.get("success", true)):
_statusLabel.text = str(payload.get("message", "课程加载失败"))
return return
var data: Variant = payload.get("data", {}) var data: Variant = payload.get("data", {})

View File

@@ -1,7 +1,6 @@
extends CanvasLayer extends CanvasLayer
class_name DatawhaleHonorRankingPanel class_name DatawhaleHonorRankingPanel
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const TEX_PANEL_FRAME = preload("res://assets/ui/datawhale_honor/honor_panel_wood_frame.png") const TEX_PANEL_FRAME = preload("res://assets/ui/datawhale_honor/honor_panel_wood_frame.png")
const TEX_WINDOW_SHELL = preload("res://assets/ui/datawhale_honor/honor_window_shell.png") const TEX_WINDOW_SHELL = preload("res://assets/ui/datawhale_honor/honor_window_shell.png")
const TEX_PARCHMENT_PANEL = preload("res://assets/ui/datawhale_honor/honor_parchment_panel.png") const TEX_PARCHMENT_PANEL = preload("res://assets/ui/datawhale_honor/honor_parchment_panel.png")
@@ -37,6 +36,7 @@ const TEX_AVATAR_FALLBACK_3 = preload("res://assets/ui/auth/generated/auth_chara
const API_PATH: String = "/rankings/datawhale-honor" const API_PATH: String = "/rankings/datawhale-honor"
const PANEL_SIZE: Vector2 = Vector2(1500, 850) const PANEL_SIZE: Vector2 = Vector2(1500, 850)
const REQUEST_LIMIT: int = 9 const REQUEST_LIMIT: int = 9
const REQUEST_TIMEOUT: float = 18.0
const LOWER_RANK_LIMIT: int = 6 const LOWER_RANK_LIMIT: int = 6
const LOWER_RANK_SLOT_HEIGHT: float = 45.0 const LOWER_RANK_SLOT_HEIGHT: float = 45.0
const LOWER_RANK_AVATAR_SIZE: float = 30.0 const LOWER_RANK_AVATAR_SIZE: float = 30.0
@@ -85,8 +85,8 @@ var _podiumRow: Control
var _rankList: VBoxContainer var _rankList: VBoxContainer
var _footerLabel: Label var _footerLabel: Label
var _statusLabel: Label var _statusLabel: Label
var _request: HTTPRequest
var _avatarRequests: Array[HTTPRequest] = [] var _avatarRequests: Array[HTTPRequest] = []
var _rankingRequestGeneration: int = 0
var _chatUi: Control var _chatUi: Control
var _chatUiPrevMouseFilter: Control.MouseFilter = Control.MOUSE_FILTER_STOP var _chatUiPrevMouseFilter: Control.MouseFilter = Control.MOUSE_FILTER_STOP
var _chatUiMouseDisabled: bool = false var _chatUiMouseDisabled: bool = false
@@ -96,6 +96,7 @@ var _isLoading: bool = false
var _debugShowRankSlots: bool = false var _debugShowRankSlots: bool = false
func _ready() -> void: func _ready() -> void:
add_to_group("whaletown_escape_dismissible")
get_tree().paused = true get_tree().paused = true
_disableChatUiMouseInput() _disableChatUiMouseInput()
_buildUi() _buildUi()
@@ -109,12 +110,23 @@ func _exit_tree() -> void:
_restoreChatUiMouseInput() _restoreChatUiMouseInput()
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if get_viewport().is_input_handled():
return
if event is InputEventKey: if event is InputEventKey:
var keyEvent := event as InputEventKey var keyEvent := event as InputEventKey
if keyEvent.pressed and not keyEvent.echo and keyEvent.keycode == KEY_ESCAPE: if keyEvent.pressed and not keyEvent.echo and keyEvent.keycode == KEY_ESCAPE:
_onClosePressed() _onClosePressed()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
func is_escape_dismissible() -> bool:
return is_inside_tree()
func get_escape_priority() -> int:
return 1000
func request_escape_close() -> void:
_onClosePressed()
func _buildUi() -> void: func _buildUi() -> void:
_rootControl = Control.new() _rootControl = Control.new()
_rootControl.name = "RankingPanelRoot" _rootControl.name = "RankingPanelRoot"
@@ -174,10 +186,6 @@ func _buildUi() -> void:
_panel.add_child(_buildRuleOverlay()) _panel.add_child(_buildRuleOverlay())
_panel.add_child(_buildCloseButton()) _panel.add_child(_buildCloseButton())
_request = HTTPRequest.new()
_request.timeout = 18.0
_request.request_completed.connect(_onRequestCompleted)
add_child(_request)
_setLoadingState() _setLoadingState()
func _buildBoardBody() -> Control: func _buildBoardBody() -> Control:
@@ -240,38 +248,28 @@ func _positionPanel() -> void:
_panel.position = (viewportSize - PANEL_SIZE * scaleFactor) * 0.5 _panel.position = (viewportSize - PANEL_SIZE * scaleFactor) * 0.5
func _fetchRanking(categoryId: String) -> void: func _fetchRanking(categoryId: String) -> void:
if _request == null:
return
if _isLoading:
_request.cancel_request()
_activeCategory = categoryId _activeCategory = categoryId
_isLoading = true _isLoading = true
_rankingRequestGeneration += 1
_setLoadingState() _setLoadingState()
var endpoint := "%s%s?category=%s&limit=%d&refresh=true" % [ var endpoint := "%s?category=%s&limit=%d&refresh=true" % [
NetworkConfig.get_api_base_url(),
API_PATH, API_PATH,
_urlEncode(_activeCategory), _urlEncode(_activeCategory),
REQUEST_LIMIT, REQUEST_LIMIT,
] ]
var err := _request.request(endpoint, PackedStringArray(), HTTPClient.METHOD_GET, "") var api_client := get_node_or_null("/root/ApiClient")
if err != OK: if api_client == null or not api_client.has_method("get_json"):
_isLoading = false _isLoading = false
_setErrorState("荣誉榜请求失败:%s" % error_string(err)) _setErrorState("荣誉榜服务不可用")
return
api_client.call("get_json", endpoint, Callable(self, "_onRequestCompleted").bind(_rankingRequestGeneration), false, REQUEST_TIMEOUT)
func _onRequestCompleted(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void: func _onRequestCompleted(success: bool, payload: Dictionary, error_info: Dictionary, request_generation: int) -> void:
if request_generation != _rankingRequestGeneration:
return
_isLoading = false _isLoading = false
if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300: if not success:
_setErrorState("荣誉榜加载失败,请稍后再试") _setErrorState(str(error_info.get("message", "荣誉榜加载失败,请稍后再试")))
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if not parsed is Dictionary:
_setErrorState("荣誉榜数据解析失败")
return
var payload := parsed as Dictionary
if not bool(payload.get("success", true)):
_setErrorState(str(payload.get("message", "荣誉榜加载失败")))
return return
var dataVariant: Variant = payload.get("data", {}) var dataVariant: Variant = payload.get("data", {})

View File

@@ -38,6 +38,7 @@ var _isOpen: bool = false
var _hasRequestedFriendList: bool = false var _hasRequestedFriendList: bool = false
func _ready() -> void: func _ready() -> void:
add_to_group("whaletown_escape_dismissible")
mouse_filter = Control.MOUSE_FILTER_IGNORE mouse_filter = Control.MOUSE_FILTER_IGNORE
_build_ui() _build_ui()
set_panel_open(false) set_panel_open(false)
@@ -60,6 +61,15 @@ func set_panel_open(open: bool) -> void:
func is_panel_open() -> bool: func is_panel_open() -> bool:
return _isOpen return _isOpen
func is_escape_dismissible() -> bool:
return _isOpen
func get_escape_priority() -> int:
return 500
func request_escape_close() -> void:
set_panel_open(false)
func toggle_panel() -> void: func toggle_panel() -> void:
set_panel_open(not _isOpen) set_panel_open(not _isOpen)
@@ -361,6 +371,19 @@ func _create_friend_row(friend: Dictionary) -> Control:
dot.add_theme_stylebox_override("panel", _create_dot_style(ONLINE_COLOR if online else OFFLINE_COLOR)) dot.add_theme_stylebox_override("panel", _create_dot_style(ONLINE_COLOR if online else OFFLINE_COLOR))
row.add_child(dot) row.add_child(dot)
if bool(friend.get("room_visitable", false)):
var visit_button := _create_small_button("房间", ACCENT_COLOR)
visit_button.name = "VisitRoomButton"
visit_button.custom_minimum_size = Vector2(56, 30)
visit_button.set_anchors_preset(Control.PRESET_CENTER_RIGHT)
visit_button.offset_left = -68
visit_button.offset_top = -15
visit_button.offset_right = -12
visit_button.offset_bottom = 15
visit_button.z_index = 2
visit_button.pressed.connect(func() -> void: _visit_friend_room(userId, username))
button.add_child(visit_button)
return button return button
func _create_request_row(request: Dictionary) -> Control: func _create_request_row(request: Dictionary) -> Control:
@@ -407,6 +430,13 @@ func _select_friend(userId: String, username: String, _online: bool) -> void:
"username": username "username": username
}) })
func _visit_friend_room(userId: String, username: String) -> void:
if userId.strip_edges().is_empty():
return
var social_manager := get_node_or_null("/root/SocialManager")
if social_manager != null and social_manager.has_method("visit_room"):
social_manager.call("visit_room", userId, username)
func _respond_request(userId: String, username: String, accepted: bool) -> void: func _respond_request(userId: String, username: String, accepted: bool) -> void:
if userId.strip_edges().is_empty(): if userId.strip_edges().is_empty():
return return

View File

@@ -42,6 +42,8 @@ func _draw() -> void:
_draw_friends() _draw_friends()
"settings": "settings":
_draw_settings() _draw_settings()
"admin":
_draw_admin()
_: _:
_draw_map() _draw_map()
@@ -101,6 +103,13 @@ func _draw_settings() -> void:
_draw_arc(center, 4.9, 0.0, TAU, 32, LINE_WIDTH) _draw_arc(center, 4.9, 0.0, TAU, 32, LINE_WIDTH)
_draw_arc(center, 2.0, 0.0, TAU, 24, DETAIL_WIDTH) _draw_arc(center, 2.0, 0.0, TAU, 24, DETAIL_WIDTH)
func _draw_admin() -> void:
_draw_round_rect(Rect2(7.0, 5.8, 13.0, 15.6), 3.0)
_draw_line(Vector2(9.5, 11.0), Vector2(17.5, 11.0), DETAIL_WIDTH)
_draw_line(Vector2(9.5, 14.5), Vector2(15.3, 14.5), DETAIL_WIDTH)
_draw_arc(Vector2(13.5, 20.5), 3.5, PI, TAU, 16, DETAIL_WIDTH)
_draw_red_dot(Vector2(20.2, 6.0))
func _draw_round_rect(rect: Rect2, radius: float, width: float = LINE_WIDTH) -> void: func _draw_round_rect(rect: Rect2, radius: float, width: float = LINE_WIDTH) -> void:
var left := rect.position.x var left := rect.position.x
var top := rect.position.y var top := rect.position.y

View File

@@ -37,13 +37,13 @@ const MAP_CONFIGS: Dictionary = {
"YSortWorld", "YSortWorld",
], ],
"markers": [ "markers": [
{"label": "码头", "icon": "anchor", "pos": Vector2(0.160, 0.395), "side": "right"}, {"label": "码头", "destinationId": "square_dock", "icon": "anchor", "pos": Vector2(0.160, 0.395), "side": "right"},
{"label": "总部", "icon": "home", "pos": Vector2(0.505, 0.188), "side": "right"}, {"label": "总部", "destinationId": "square_headquarters", "icon": "home", "pos": Vector2(0.505, 0.188), "side": "right"},
{"label": "广场", "icon": "whale", "pos": Vector2(0.505, 0.515), "side": "right"}, {"label": "广场", "destinationId": "square_center", "icon": "whale", "pos": Vector2(0.505, 0.515), "side": "right"},
{"label": "小屋", "icon": "home", "pos": Vector2(0.830, 0.400), "side": "right"}, {"label": "小屋", "destinationId": "square_cottage", "icon": "home", "pos": Vector2(0.830, 0.400), "side": "right"},
{"label": "工坊", "icon": "tool", "pos": Vector2(0.752, 0.760), "side": "left"}, {"label": "工坊", "destinationId": "square_workshop", "icon": "tool", "pos": Vector2(0.752, 0.760), "side": "left"},
{"label": "公告", "icon": "notice", "pos": Vector2(0.288, 0.838), "side": "right"}, {"label": "公告", "destinationId": "square_notice", "icon": "notice", "pos": Vector2(0.288, 0.838), "side": "right"},
{"label": "入口", "icon": "gate", "pos": Vector2(0.500, 0.900), "side": "right"}, {"label": "入口", "destinationId": "square_work_zone_gate", "icon": "gate", "pos": Vector2(0.500, 0.900), "side": "right"},
], ],
}, },
"WorkZone": { "WorkZone": {
@@ -57,13 +57,13 @@ const MAP_CONFIGS: Dictionary = {
"YSortWorld", "YSortWorld",
], ],
"markers": [ "markers": [
{"label": "商城", "icon": "home", "pos": Vector2(0.500, 0.180), "side": "right"}, {"label": "商城", "destinationId": "work_mall", "icon": "home", "pos": Vector2(0.500, 0.180), "side": "right"},
{"label": "咖啡店", "icon": "home", "pos": Vector2(0.092, 0.620), "side": "right"}, {"label": "咖啡店", "destinationId": "work_cafe_gate", "icon": "home", "pos": Vector2(0.092, 0.620), "side": "right"},
{"label": "任务", "icon": "notice", "pos": Vector2(0.304, 0.600), "side": "right"}, {"label": "任务", "destinationId": "work_jobs", "icon": "notice", "pos": Vector2(0.304, 0.600), "side": "right"},
{"label": "课程", "icon": "notice", "pos": Vector2(0.694, 0.500), "side": "left"}, {"label": "课程", "destinationId": "work_courses", "icon": "notice", "pos": Vector2(0.694, 0.500), "side": "left"},
{"label": "AI站", "icon": "tool", "pos": Vector2(0.676, 0.785), "side": "left"}, {"label": "AI站", "destinationId": "work_ai", "icon": "tool", "pos": Vector2(0.676, 0.785), "side": "left"},
{"label": "鲸币", "icon": "whale", "pos": Vector2(0.920, 0.785), "side": "left"}, {"label": "鲸币", "destinationId": "work_exchange", "icon": "whale", "pos": Vector2(0.920, 0.785), "side": "left"},
{"label": "入口", "icon": "gate", "pos": Vector2(0.500, 0.900), "side": "right"}, {"label": "入口", "destinationId": "work_entrance", "icon": "gate", "pos": Vector2(0.500, 0.900), "side": "right"},
], ],
}, },
"CafeInterior": { "CafeInterior": {
@@ -74,11 +74,19 @@ const MAP_CONFIGS: Dictionary = {
"CafeServiceHallBase", "CafeServiceHallBase",
], ],
"markers": [ "markers": [
{"label": "服务台", "icon": "whale", "pos": Vector2(0.500, 0.465), "side": "right"}, {"label": "服务台", "destinationId": "cafe_counter", "icon": "whale", "pos": Vector2(0.500, 0.465), "side": "right"},
{"label": "陪伴区", "icon": "notice", "pos": Vector2(0.240, 0.280), "side": "right"}, {"label": "陪伴区", "destinationId": "cafe_companion", "icon": "notice", "pos": Vector2(0.240, 0.280), "side": "right"},
{"label": "出口", "icon": "gate", "pos": Vector2(0.500, 0.855), "side": "right"}, {"label": "出口", "destinationId": "cafe_entrance", "icon": "gate", "pos": Vector2(0.500, 0.855), "side": "right"},
], ],
}, },
"PersonalSpace": {
"title": "我的房间",
"worldSize": Vector2(1024, 768),
"sourceNodes": [
"RoomBase",
],
"markers": [],
},
} }
var _panel: PanelContainer var _panel: PanelContainer
@@ -90,8 +98,13 @@ var _mapCamera: Camera2D
var _titleLabel: Label var _titleLabel: Label
var _markerNodes: Array[Control] = [] var _markerNodes: Array[Control] = []
var _isOpen: bool = false var _isOpen: bool = false
var _travelDestinations: Dictionary = {}
var _travelLocked: bool = false
var _destinationMenu: PopupMenu
var _destinationMenuIds: Dictionary = {}
func _ready() -> void: func _ready() -> void:
add_to_group("whaletown_escape_dismissible")
mouse_filter = Control.MOUSE_FILTER_IGNORE mouse_filter = Control.MOUSE_FILTER_IGNORE
_build_ui() _build_ui()
set_panel_open(false) set_panel_open(false)
@@ -107,6 +120,8 @@ func _notification(what: int) -> void:
_anchor_panel() _anchor_panel()
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if get_viewport().is_input_handled():
return
if not _isOpen: if not _isOpen:
return return
if event is InputEventKey: if event is InputEventKey:
@@ -123,10 +138,20 @@ func set_panel_open(open: bool) -> void:
_panel.visible = _isOpen _panel.visible = _isOpen
if _isOpen: if _isOpen:
_rebuild_minimap_world() _rebuild_minimap_world()
_load_travel_destinations()
func is_panel_open() -> bool: func is_panel_open() -> bool:
return _isOpen return _isOpen
func is_escape_dismissible() -> bool:
return _isOpen
func get_escape_priority() -> int:
return 600
func request_escape_close() -> void:
set_panel_open(false)
func toggle_panel() -> void: func toggle_panel() -> void:
set_panel_open(not _isOpen) set_panel_open(not _isOpen)
@@ -152,6 +177,9 @@ func _build_ui() -> void:
content.add_child(_build_header()) content.add_child(_build_header())
content.add_child(_build_map_view()) content.add_child(_build_map_view())
_destinationMenu = PopupMenu.new()
_destinationMenu.id_pressed.connect(_on_destination_menu_selected)
add_child(_destinationMenu)
func _build_header() -> Control: func _build_header() -> Control:
var header := HBoxContainer.new() var header := HBoxContainer.new()
@@ -173,6 +201,20 @@ func _build_header() -> Control:
title.add_theme_font_size_override("font_size", 24) title.add_theme_font_size_override("font_size", 24)
header.add_child(title) header.add_child(title)
var destinationButton := Button.new()
destinationButton.text = "目的地"
destinationButton.tooltip_text = "查看全部快速传送目的地"
destinationButton.custom_minimum_size = Vector2(76, 38)
destinationButton.focus_mode = Control.FOCUS_NONE
destinationButton.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
destinationButton.add_theme_font_size_override("font_size", 14)
destinationButton.add_theme_color_override("font_color", ACCENT_COLOR)
destinationButton.add_theme_stylebox_override("normal", _create_round_style(Color(0.902, 0.962, 0.995, 1.0), 14))
destinationButton.add_theme_stylebox_override("hover", _create_round_style(Color(0.818, 0.925, 0.980, 1.0), 14))
destinationButton.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
destinationButton.pressed.connect(func() -> void: _show_destination_menu(destinationButton))
header.add_child(destinationButton)
var closeButton := Button.new() var closeButton := Button.new()
closeButton.text = "×" closeButton.text = "×"
closeButton.tooltip_text = "关闭地图" closeButton.tooltip_text = "关闭地图"
@@ -263,6 +305,16 @@ func _create_marker(marker: Dictionary) -> Control:
button.add_theme_stylebox_override("hover", _create_marker_style(Color(0.925, 0.966, 0.996, 0.98), ACCENT_COLOR)) button.add_theme_stylebox_override("hover", _create_marker_style(Color(0.925, 0.966, 0.996, 0.98), ACCENT_COLOR))
button.add_theme_stylebox_override("pressed", _create_marker_style(Color(0.858, 0.925, 0.980, 0.98), ACCENT_COLOR.darkened(0.05))) button.add_theme_stylebox_override("pressed", _create_marker_style(Color(0.858, 0.925, 0.980, 0.98), ACCENT_COLOR.darkened(0.05)))
button.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) button.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
var destinationId := str(marker.get("destinationId", "")).strip_edges()
var destination: Dictionary = _travelDestinations.get(destinationId, {})
var unlocked := destinationId.is_empty() or _travelDestinations.is_empty() or bool(destination.get("unlocked", false))
button.disabled = not unlocked or _travelLocked
button.tooltip_text = "点击快速传送" if unlocked else "首次到访后解锁"
if not unlocked:
button.modulate = Color(0.66, 0.69, 0.72, 0.72)
button.pressed.connect(func() -> void: return)
else:
button.pressed.connect(func() -> void: _request_travel(destinationId))
var row := HBoxContainer.new() var row := HBoxContainer.new()
row.mouse_filter = Control.MOUSE_FILTER_IGNORE row.mouse_filter = Control.MOUSE_FILTER_IGNORE
@@ -281,7 +333,7 @@ func _create_marker(marker: Dictionary) -> Control:
var label := Label.new() var label := Label.new()
label.mouse_filter = Control.MOUSE_FILTER_IGNORE label.mouse_filter = Control.MOUSE_FILTER_IGNORE
label.text = str(marker.get("label", "地点")) label.text = ("🔒 " if not unlocked else "") + str(marker.get("label", "地点"))
label.add_theme_color_override("font_color", TEXT_COLOR) label.add_theme_color_override("font_color", TEXT_COLOR)
label.add_theme_font_size_override("font_size", 14) label.add_theme_font_size_override("font_size", 14)
row.add_child(label) row.add_child(label)
@@ -295,6 +347,119 @@ func _create_marker(marker: Dictionary) -> Control:
) )
return button return button
func _load_travel_destinations() -> void:
var api := get_node_or_null("/root/ApiClient")
if api == null:
return
api.call("get_json", "/world/travel-destinations", func(success: bool, response: Dictionary, _error: Dictionary) -> void:
if not success:
return
var data_variant: Variant = response.get("data", [])
if not (data_variant is Array):
return
_travelDestinations.clear()
for entry in data_variant as Array:
if entry is Dictionary:
var destination: Dictionary = entry
_travelDestinations[str(destination.get("id", ""))] = destination
_render_markers()
, true)
func _show_destination_menu(origin: Control) -> void:
if not is_instance_valid(_destinationMenu):
return
_destinationMenu.clear()
_destinationMenuIds.clear()
if _travelDestinations.is_empty():
_destinationMenu.add_item("正在读取目的地…", 0)
_destinationMenu.set_item_disabled(0, true)
else:
var destinations: Array[Dictionary] = []
for destination_variant in _travelDestinations.values():
if destination_variant is Dictionary:
destinations.append(destination_variant as Dictionary)
destinations.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
var map_order := {"whale_port": 0, "work_zone": 1, "whale_cafe": 2, "personal_space": 3}
var order_a := int(map_order.get(str(a.get("mapId", "")), 9))
var order_b := int(map_order.get(str(b.get("mapId", "")), 9))
if order_a != order_b:
return order_a < order_b
return str(a.get("label", "")).naturalnocasecmp_to(str(b.get("label", ""))) < 0
)
var current_map := ""
var menu_id := 1
for destination in destinations:
var map_id := str(destination.get("mapId", ""))
if map_id != current_map:
if not current_map.is_empty():
_destinationMenu.add_separator()
_destinationMenu.add_item("%s" % _map_label(map_id), menu_id)
_destinationMenu.set_item_disabled(_destinationMenu.item_count - 1, true)
menu_id += 1
current_map = map_id
var unlocked := bool(destination.get("unlocked", false))
_destinationMenu.add_item(("" if unlocked else "🔒 ") + str(destination.get("label", "地点")), menu_id)
_destinationMenu.set_item_disabled(_destinationMenu.item_count - 1, not unlocked or _travelLocked)
_destinationMenuIds[menu_id] = str(destination.get("id", ""))
menu_id += 1
_destinationMenu.reset_size()
_destinationMenu.position = Vector2i(origin.get_screen_position() + Vector2(0.0, origin.size.y))
_destinationMenu.popup()
func _on_destination_menu_selected(menu_id: int) -> void:
var destination_id := str(_destinationMenuIds.get(menu_id, ""))
if not destination_id.is_empty():
_request_travel(destination_id)
func _request_travel(destinationId: String) -> void:
if destinationId.is_empty() or _travelLocked:
return
var socialManager := get_node_or_null("/root/SocialManager")
if socialManager == null or not socialManager.has_method("request_travel"):
return
_travelLocked = true
_render_markers()
socialManager.call("request_travel", destinationId, Callable(self, "_on_travel_authorized"))
func _on_travel_authorized(success: bool, destination: Dictionary) -> void:
if not success:
await get_tree().create_timer(1.0).timeout
_travelLocked = false
_render_markers()
return
var mapId := str(destination.get("mapId", ""))
var position := _scene_position_for_destination(mapId, Vector2(float(destination.get("x", 0.0)), float(destination.get("y", 0.0))))
var sceneName: String = str({"whale_port": "square", "work_zone": "work_zone", "whale_cafe": "cafe_interior", "personal_space": "personal_space"}.get(mapId, ""))
if sceneName.is_empty():
await get_tree().create_timer(1.0).timeout
_travelLocked = false
_render_markers()
return
set_panel_open(false)
if sceneName == "personal_space":
SceneManager.clear_room_visit_context()
SceneManager.set_next_destination_id(str(destination.get("id", "")))
SceneManager.set_next_scene_position(position)
SceneManager.change_scene(sceneName)
func _scene_position_for_destination(map_id: String, map_position: Vector2) -> Vector2:
# 服务端旅行目录使用小地图坐标;游戏场景则以地图中心为原点。
var origin := {
"whale_port": Vector2(1280, 960),
"work_zone": Vector2(1280, 960),
"whale_cafe": Vector2(768, 512),
"personal_space": Vector2(768, 512),
}.get(map_id, Vector2.ZERO) as Vector2
return map_position - origin
func _map_label(map_id: String) -> String:
return {
"whale_port": "中心广场",
"work_zone": "打工区",
"whale_cafe": "鲸鱼咖啡馆",
"personal_space": "我的房间",
}.get(map_id, map_id)
func _anchor_panel() -> void: func _anchor_panel() -> void:
_panel.set_anchors_preset(Control.PRESET_TOP_RIGHT) _panel.set_anchors_preset(Control.PRESET_TOP_RIGHT)
_panel.offset_left = -PANEL_SIZE.x - PANEL_MARGIN_RIGHT _panel.offset_left = -PANEL_SIZE.x - PANEL_MARGIN_RIGHT

View File

@@ -24,6 +24,7 @@ var _chatUiPrevMouseFilter: Control.MouseFilter = Control.MOUSE_FILTER_STOP
var _chatUiMouseDisabled: bool = false var _chatUiMouseDisabled: bool = false
func _ready() -> void: func _ready() -> void:
add_to_group("whaletown_escape_dismissible")
get_tree().paused = true get_tree().paused = true
_disableChatUiMouseInput() _disableChatUiMouseInput()
@@ -39,6 +40,15 @@ func _ready() -> void:
func _exit_tree() -> void: func _exit_tree() -> void:
_restoreChatUiMouseInput() _restoreChatUiMouseInput()
func is_escape_dismissible() -> bool:
return is_inside_tree()
func get_escape_priority() -> int:
return 1000
func request_escape_close() -> void:
_onClosePressed()
func _setupDots() -> void: func _setupDots() -> void:
for child in dotsContainer.get_children(): for child in dotsContainer.get_children():
child.queue_free() child.queue_free()

View File

@@ -8,7 +8,7 @@ extends Control
# ============================================================================ # ============================================================================
const HUD_MARGIN: Vector2 = Vector2(16, 16) const HUD_MARGIN: Vector2 = Vector2(16, 16)
const SHORTCUT_BAR_SIZE: Vector2 = Vector2(350, 86) const SHORTCUT_BAR_SIZE: Vector2 = Vector2(476, 86)
const PLAYER_PROFILE_BUTTON_SIZE: Vector2 = Vector2(210, 78) const PLAYER_PROFILE_BUTTON_SIZE: Vector2 = Vector2(210, 78)
const PLAYER_AVATAR_SIZE: Vector2 = Vector2(58, 58) const PLAYER_AVATAR_SIZE: Vector2 = Vector2(58, 58)
const HUD_SEPARATION: int = 18 const HUD_SEPARATION: int = 18
@@ -24,6 +24,10 @@ var _walletLabel: Label
var _avatarLabel: Label var _avatarLabel: Label
var _avatarPanel: PanelContainer var _avatarPanel: PanelContainer
var _walletRefreshTimer: Timer var _walletRefreshTimer: Timer
var _taskShortcutButton: Button
var _taskRewardBadge: Label
var _shortcutRow: HBoxContainer
var _adminShortcutButton: Button
var _currentUsername: String = "玩家" var _currentUsername: String = "玩家"
var _walletBalance: int = 0 var _walletBalance: int = 0
@@ -37,6 +41,7 @@ func _ready() -> void:
_subscribe_to_events() _subscribe_to_events()
_load_current_user() _load_current_user()
_refresh_wallet() _refresh_wallet()
call_deferred("_refresh_admin_shortcut")
func _exit_tree() -> void: func _exit_tree() -> void:
var eventSystem := _get_event_system() var eventSystem := _get_event_system()
@@ -59,6 +64,11 @@ func _exit_tree() -> void:
var callback := Callable(self, "_on_auth_state_changed") var callback := Callable(self, "_on_auth_state_changed")
if authManager.is_connected("auth_state_changed", callback): if authManager.is_connected("auth_state_changed", callback):
authManager.disconnect("auth_state_changed", callback) authManager.disconnect("auth_state_changed", callback)
var taskManager := get_node_or_null("/root/TaskManager")
if taskManager != null and taskManager.has_signal("board_changed"):
var taskCallback := Callable(self, "_on_task_board_changed")
if taskManager.is_connected("board_changed", taskCallback):
taskManager.disconnect("board_changed", taskCallback)
func _build_ui() -> void: func _build_ui() -> void:
var rootRow := HBoxContainer.new() var rootRow := HBoxContainer.new()
@@ -87,15 +97,35 @@ func _build_shortcut_bar() -> PanelContainer:
panel.add_child(margin) panel.add_child(margin)
var row := HBoxContainer.new() var row := HBoxContainer.new()
_shortcutRow = row
row.alignment = BoxContainer.ALIGNMENT_CENTER row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 8) row.add_theme_constant_override("separation", 8)
margin.add_child(row) margin.add_child(row)
row.add_child(_create_shortcut_button("map", "地图", _on_map_pressed)) row.add_child(_create_shortcut_button("map", "地图", _on_map_pressed))
row.add_child(_create_shortcut_button("task", "任务", _on_task_pressed)) _taskShortcutButton = _create_shortcut_button("task", "任务", _on_task_pressed)
_taskShortcutButton.tooltip_text = "打开任务书"
_taskRewardBadge = Label.new()
_taskRewardBadge.mouse_filter = Control.MOUSE_FILTER_IGNORE
_taskRewardBadge.visible = false
_taskRewardBadge.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_taskRewardBadge.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_taskRewardBadge.add_theme_color_override("font_color", Color.WHITE)
_taskRewardBadge.add_theme_font_size_override("font_size", 12)
_taskRewardBadge.add_theme_stylebox_override("normal", _create_pill_style(Color(0.92, 0.30, 0.24), 12))
_taskRewardBadge.set_anchors_preset(Control.PRESET_TOP_RIGHT)
_taskRewardBadge.offset_left = -20
_taskRewardBadge.offset_top = 1
_taskRewardBadge.offset_right = 4
_taskRewardBadge.offset_bottom = 25
_taskRewardBadge.z_index = 1
_taskShortcutButton.add_child(_taskRewardBadge)
row.add_child(_taskShortcutButton)
row.add_child(_create_shortcut_button("backpack", "背包", _on_backpack_pressed)) row.add_child(_create_shortcut_button("backpack", "背包", _on_backpack_pressed))
row.add_child(_create_shortcut_button("friends", "好友", _on_friends_pressed)) row.add_child(_create_shortcut_button("friends", "好友", _on_friends_pressed))
row.add_child(_create_shortcut_button("activity", "通知", _on_notifications_pressed))
row.add_child(_create_shortcut_button("settings", "设置", _on_settings_pressed)) row.add_child(_create_shortcut_button("settings", "设置", _on_settings_pressed))
_refresh_admin_shortcut()
return panel return panel
@@ -239,6 +269,15 @@ func _subscribe_to_events() -> void:
var callback := Callable(self, "_on_auth_state_changed") var callback := Callable(self, "_on_auth_state_changed")
if not authManager.is_connected("auth_state_changed", callback): if not authManager.is_connected("auth_state_changed", callback):
authManager.connect("auth_state_changed", callback) authManager.connect("auth_state_changed", callback)
var taskManager := get_node_or_null("/root/TaskManager")
if taskManager != null and taskManager.has_signal("board_changed"):
var taskCallback := Callable(self, "_on_task_board_changed")
if not taskManager.is_connected("board_changed", taskCallback):
taskManager.connect("board_changed", taskCallback)
if taskManager.has_method("get_board"):
var boardVariant: Variant = taskManager.call("get_board")
if boardVariant is Dictionary:
_on_task_board_changed(boardVariant as Dictionary)
func _load_current_user() -> void: func _load_current_user() -> void:
var authManager := get_node_or_null("/root/AuthManager") var authManager := get_node_or_null("/root/AuthManager")
@@ -262,9 +301,11 @@ func _on_auth_state_changed(isAuthenticated: bool, user: Dictionary) -> void:
if not isAuthenticated: if not isAuthenticated:
_set_username("玩家") _set_username("玩家")
_reset_wallet() _reset_wallet()
_refresh_admin_shortcut()
return return
_apply_auth_user_payload({"user": user}) _apply_auth_user_payload({"user": user})
_refresh_wallet() _refresh_wallet()
_refresh_admin_shortcut()
func _apply_auth_user_payload(data: Dictionary) -> void: func _apply_auth_user_payload(data: Dictionary) -> void:
var userVariant: Variant = data.get("user", {}) var userVariant: Variant = data.get("user", {})
@@ -272,10 +313,51 @@ func _apply_auth_user_payload(data: Dictionary) -> void:
var username := str((userVariant as Dictionary).get("username", "")).strip_edges() var username := str((userVariant as Dictionary).get("username", "")).strip_edges()
if not username.is_empty(): if not username.is_empty():
_set_username(username) _set_username(username)
_refresh_admin_shortcut()
func _on_auth_logout(_data: Variant = null) -> void: func _on_auth_logout(_data: Variant = null) -> void:
_set_username("玩家") _set_username("玩家")
_reset_wallet() _reset_wallet()
_on_task_board_changed({})
_refresh_admin_shortcut()
func _on_task_board_changed(board: Dictionary) -> void:
if not is_instance_valid(_taskShortcutButton):
return
var claimableCount: int = _count_claimable_task_rewards(board)
if claimableCount > 0:
_taskShortcutButton.tooltip_text = "任务书:有 %d 项奖励可领取" % claimableCount
_taskShortcutButton.modulate = Color(1.0, 0.855, 0.470, 1.0)
if is_instance_valid(_taskRewardBadge):
_taskRewardBadge.text = str(mini(claimableCount, 9)) if claimableCount < 10 else "9+"
_taskRewardBadge.visible = true
return
_taskShortcutButton.tooltip_text = "打开任务书"
_taskShortcutButton.modulate = Color.WHITE
if is_instance_valid(_taskRewardBadge):
_taskRewardBadge.visible = false
func _count_claimable_task_rewards(board: Dictionary) -> int:
var claimableCount: int = 0
claimableCount += _count_claimable_tasks(board.get("newbie_tasks", []))
claimableCount += _count_claimable_tasks(board.get("weekly_tasks", []))
var weeklyBonusVariant: Variant = board.get("weekly_bonus", {})
if weeklyBonusVariant is Dictionary:
var weeklyBonus: Dictionary = weeklyBonusVariant as Dictionary
if bool(weeklyBonus.get("claimable", false)):
claimableCount += 1
return claimableCount
func _count_claimable_tasks(tasksVariant: Variant) -> int:
if not (tasksVariant is Array):
return 0
var claimableCount: int = 0
for taskVariant: Variant in tasksVariant as Array:
if taskVariant is Dictionary:
var task: Dictionary = taskVariant as Dictionary
if bool(task.get("claimable", false)):
claimableCount += 1
return claimableCount
func _on_appearance_avatar_changed(_data: Dictionary) -> void: func _on_appearance_avatar_changed(_data: Dictionary) -> void:
_apply_current_avatar() _apply_current_avatar()
@@ -379,8 +461,15 @@ func _on_map_pressed() -> void:
if eventSystem != null: if eventSystem != null:
eventSystem.call("emit_event", EventNames.HUD_MAP_TOGGLE, {}) eventSystem.call("emit_event", EventNames.HUD_MAP_TOGGLE, {})
func _on_notifications_pressed() -> void:
var socialManager := get_node_or_null("/root/SocialManager")
if socialManager != null and socialManager.has_method("toggle_notifications"):
socialManager.call("toggle_notifications")
func _on_task_pressed() -> void: func _on_task_pressed() -> void:
_emit_status_message("任务入口稍后接入") var eventSystem := _get_event_system()
if eventSystem != null:
eventSystem.call("emit_event", EventNames.HUD_TASK_BOOK_REQUESTED, {})
func _on_backpack_pressed() -> void: func _on_backpack_pressed() -> void:
var eventSystem := _get_event_system() var eventSystem := _get_event_system()
@@ -402,6 +491,35 @@ func _on_avatar_pressed() -> void:
if eventSystem != null: if eventSystem != null:
eventSystem.call("emit_event", EventNames.HUD_SETTINGS_REQUESTED, {}) eventSystem.call("emit_event", EventNames.HUD_SETTINGS_REQUESTED, {})
func _on_admin_test_lab_pressed() -> void:
var eventSystem := _get_event_system()
if eventSystem != null:
eventSystem.call("emit_event", EventNames.HUD_ADMIN_TEST_LAB_REQUESTED, {})
func _refresh_admin_shortcut() -> void:
if not is_instance_valid(_shortcutRow):
return
var shouldShow: bool = _is_current_user_admin()
if shouldShow and not is_instance_valid(_adminShortcutButton):
_adminShortcutButton = _create_shortcut_button("admin", "管理", _on_admin_test_lab_pressed)
_adminShortcutButton.tooltip_text = "管理员测试实验室"
_shortcutRow.add_child(_adminShortcutButton)
elif not shouldShow and is_instance_valid(_adminShortcutButton):
_adminShortcutButton.queue_free()
_adminShortcutButton = null
func _is_current_user_admin() -> bool:
var authManager := get_node_or_null("/root/AuthManager")
if authManager == null or not authManager.has_method("is_authenticated") or not bool(authManager.call("is_authenticated")):
return false
if not authManager.has_method("get_current_user"):
return false
var userVariant: Variant = authManager.call("get_current_user")
if not (userVariant is Dictionary):
return false
var user: Dictionary = userVariant as Dictionary
return int(user.get("role", 0)) == 9
func _emit_status_message(message: String) -> void: func _emit_status_message(message: String) -> void:
var eventSystem := _get_event_system() var eventSystem := _get_event_system()
if eventSystem != null: if eventSystem != null:

View File

@@ -38,6 +38,7 @@ const DEFAULT_SETTINGS: Dictionary = {
"ui_scale": 1.00, "ui_scale": 1.00,
"fullscreen": false, "fullscreen": false,
"show_interaction_hints": true, "show_interaction_hints": true,
"show_interaction_points": false,
"show_name_always": false, "show_name_always": false,
"show_chat_bubbles": true, "show_chat_bubbles": true,
"world_notifications": true, "world_notifications": true,
@@ -45,6 +46,8 @@ const DEFAULT_SETTINGS: Dictionary = {
"friend_request_notifications": true, "friend_request_notifications": true,
"allow_nearby_private": true, "allow_nearby_private": true,
"allow_nearby_friend_requests": true, "allow_nearby_friend_requests": true,
"allow_nearby_profile": true,
"room_visit_policy": "friends",
"mute_ui_sfx": false, "mute_ui_sfx": false,
} }
@@ -73,6 +76,7 @@ var _transitionTween: Tween
var _avatarUploadPending: bool = false var _avatarUploadPending: bool = false
func _ready() -> void: func _ready() -> void:
add_to_group("whaletown_escape_dismissible")
visible = false visible = false
mouse_filter = Control.MOUSE_FILTER_IGNORE mouse_filter = Control.MOUSE_FILTER_IGNORE
set_process(false) set_process(false)
@@ -95,6 +99,8 @@ func _notification(what: int) -> void:
_position_panel() _position_panel()
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if get_viewport().is_input_handled():
return
if not _isOpen: if not _isOpen:
return return
if event is InputEventKey: if event is InputEventKey:
@@ -136,6 +142,15 @@ func hide_panel(saveBeforeClose: bool = false) -> void:
func is_panel_open() -> bool: func is_panel_open() -> bool:
return _isOpen return _isOpen
func is_escape_dismissible() -> bool:
return _isOpen
func get_escape_priority() -> int:
return 700
func request_escape_close() -> void:
hide_panel(true)
func _build_ui() -> void: func _build_ui() -> void:
_overlay = ColorRect.new() _overlay = ColorRect.new()
_overlay.name = "SettingsDimOverlay" _overlay.name = "SettingsDimOverlay"
@@ -344,6 +359,7 @@ func _render_basic_content() -> void:
_content.add_child(_create_section("基础设置", [ _content.add_child(_create_section("基础设置", [
_create_toggle_row("window", "全屏显示", "fullscreen", "适合专注游玩,窗口模式方便调试"), _create_toggle_row("window", "全屏显示", "fullscreen", "适合专注游玩,窗口模式方便调试"),
_create_toggle_row("eye", "显示互动提示", "show_interaction_hints", "靠近 NPC、好友或公告板时显示按键提示"), _create_toggle_row("eye", "显示互动提示", "show_interaction_hints", "靠近 NPC、好友或公告板时显示按键提示"),
_create_toggle_row("eye", "显示交互点", "show_interaction_points", "用白色圆圈直接标出当前地图内的交互目标"),
_create_toggle_row("account", "始终显示名字", "show_name_always", "关闭时只在需要时显示玩家名称"), _create_toggle_row("account", "始终显示名字", "show_name_always", "关闭时只在需要时显示玩家名称"),
])) ]))
_content.add_child(_create_section("音频设置", [ _content.add_child(_create_section("音频设置", [
@@ -365,25 +381,30 @@ func _render_chat_content() -> void:
_content.add_child(_create_section("聊天与社交", [ _content.add_child(_create_section("聊天与社交", [
_create_toggle_row("chat", "世界频道提醒", "world_notifications", "收到世界频道消息时保留轻提示"), _create_toggle_row("chat", "世界频道提醒", "world_notifications", "收到世界频道消息时保留轻提示"),
_create_toggle_row("chat", "私聊提醒", "private_notifications", "好友或附近玩家私聊时提示"), _create_toggle_row("chat", "私聊提醒", "private_notifications", "好友或附近玩家私聊时提示"),
_create_toggle_row("account", "好友请求提醒", "friend_request_notifications", "对方按 F 发起好友申请时显示在好友列表"), _create_toggle_row("account", "好友请求提醒", "friend_request_notifications", "附近玩家发起好友申请时显示在好友列表"),
_create_toggle_row("chat", "显示聊天气泡", "show_chat_bubbles", "气泡发送会同时进入世界频道"), _create_toggle_row("chat", "显示聊天气泡", "show_chat_bubbles", "气泡发送会同时进入世界频道"),
])) ]))
_content.add_child(_create_section("附近玩家权限", [ _content.add_child(_create_section("附近玩家权限", [
_create_toggle_row("eye", "允许查看我的名片", "allow_nearby_profile", "关闭后陌生玩家无法在附近打开你的社区名片"),
_create_toggle_row("chat", "允许附近私聊", "allow_nearby_private", "附近玩家按 E 可以发起悄悄话"), _create_toggle_row("chat", "允许附近私聊", "allow_nearby_private", "附近玩家按 E 可以发起悄悄话"),
_create_toggle_row("account", "允许好友申请", "allow_nearby_friend_requests", "附近玩家按 F 可以发送好友申请"), _create_toggle_row("account", "允许好友申请", "allow_nearby_friend_requests", "附近玩家可通过互动列表发送好友申请"),
]))
_content.add_child(_create_section("个人空间访问", [
_create_room_visit_policy_row(),
])) ]))
func _render_controls_content() -> void: func _render_controls_content() -> void:
_content.add_child(_create_section("操作说明", [ _content.add_child(_create_section("操作说明", [
_create_key_row("W A S D", "移动角色"), _create_key_row("W A S D", "移动角色"),
_create_key_row("方向键", "备用移动"), _create_key_row("方向键", "附近互动列表选择"),
_create_key_row("E", "互动 / 附近私聊"), _create_key_row("E", "执行选中的互动"),
_create_key_row("F", "发送好友申请"), _create_key_row("Esc", "关闭当前界面或结束当前互动"),
_create_key_row("T", "打开聊天输入"), _create_key_row("T", "打开聊天输入"),
_create_key_row("Enter", "发送聊天消息"), _create_key_row("Enter", "发送聊天消息"),
])) ]))
_content.add_child(_create_section("操作辅助", [ _content.add_child(_create_section("操作辅助", [
_create_toggle_row("eye", "显示互动提示", "show_interaction_hints", "靠近可交互目标时显示按键提示"), _create_toggle_row("eye", "显示互动提示", "show_interaction_hints", "靠近可交互目标时显示按键提示"),
_create_toggle_row("eye", "显示交互点", "show_interaction_points", "用白色圆圈直接标出当前地图内的交互目标"),
_create_hint_row("当前版本使用固定键位;这里的开关会控制地图内的互动提示显示。"), _create_hint_row("当前版本使用固定键位;这里的开关会控制地图内的互动提示显示。"),
])) ]))
@@ -509,6 +530,58 @@ func _create_toggle_row(iconName: String, labelText: String, key: String, detail
return row return row
func _create_room_visit_policy_row() -> Control:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(0, 54)
row.add_theme_constant_override("separation", 16)
row.alignment = BoxContainer.ALIGNMENT_CENTER
var icon := ICON_SCRIPT.new() as Control
icon.set("iconName", "account")
icon.set("active", true)
icon.custom_minimum_size = Vector2(34, 34)
row.add_child(icon)
var text_box := VBoxContainer.new()
text_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
text_box.alignment = BoxContainer.ALIGNMENT_CENTER
text_box.add_theme_constant_override("separation", 2)
row.add_child(text_box)
var label := Label.new()
label.text = "谁可以参观我的房间"
label.add_theme_color_override("font_color", TEXT_COLOR)
label.add_theme_font_size_override("font_size", 17)
text_box.add_child(label)
var detail := Label.new()
detail.text = "拉黑关系始终无法访问;房主始终可编辑。"
detail.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
detail.add_theme_color_override("font_color", MUTED_COLOR)
detail.add_theme_font_size_override("font_size", 13)
text_box.add_child(detail)
var selector := OptionButton.new()
selector.custom_minimum_size = Vector2(126, 38)
selector.focus_mode = Control.FOCUS_NONE
selector.add_item("仅好友")
selector.set_item_metadata(0, "friends")
selector.add_item("公开")
selector.set_item_metadata(1, "public")
selector.add_item("关闭访问")
selector.set_item_metadata(2, "closed")
var current_policy: String = str(_settings.get("room_visit_policy", "friends"))
for index in range(selector.item_count):
if str(selector.get_item_metadata(index)) == current_policy:
selector.select(index)
break
selector.item_selected.connect(func(index: int) -> void:
_settings["room_visit_policy"] = str(selector.get_item_metadata(index))
_apply_preview_settings()
)
row.add_child(selector)
return row
func _create_scale_row() -> Control: func _create_scale_row() -> Control:
var row := HBoxContainer.new() var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(0, 48) row.custom_minimum_size = Vector2(0, 48)

337
scenes/ui/TaskBookPanel.gd Normal file
View File

@@ -0,0 +1,337 @@
extends CanvasLayer
const PANEL_SIZE := Vector2(980, 860)
const TEXT_COLOR := Color(0.10, 0.18, 0.27)
const MUTED_COLOR := Color(0.38, 0.48, 0.58)
const ACCENT_COLOR := Color(0.10, 0.49, 0.85)
const COIN_COLOR := Color(0.94, 0.58, 0.08)
const WELCOME_DIALOG_SCENE: PackedScene = preload("res://scenes/ui/welcome_dialog.tscn")
const WELCOME_DIALOG_NAME := "WelcomeDialog"
var _root: Control
var _panel: PanelContainer
var _content: VBoxContainer
var _status_label: Label
var _is_open: bool = false
func _ready() -> void:
layer = 32
add_to_group("whaletown_escape_dismissible")
_build_ui()
_connect_services()
func _exit_tree() -> void:
var event_system := get_node_or_null("/root/EventSystem")
if event_system != null:
event_system.call("disconnect_event", EventNames.HUD_TASK_BOOK_REQUESTED, _on_task_book_requested, self)
var task_manager := get_node_or_null("/root/TaskManager")
if task_manager != null:
var board_callback := Callable(self, "_on_board_changed")
if task_manager.has_signal("board_changed") and task_manager.is_connected("board_changed", board_callback):
task_manager.disconnect("board_changed", board_callback)
var failed_callback := Callable(self, "_on_board_failed")
if task_manager.has_signal("board_failed") and task_manager.is_connected("board_failed", failed_callback):
task_manager.disconnect("board_failed", failed_callback)
func show_panel() -> void:
_is_open = true
_root.visible = true
var task_manager := get_node_or_null("/root/TaskManager")
if task_manager != null:
if task_manager.has_method("is_loaded") and bool(task_manager.call("is_loaded")):
var board_variant: Variant = task_manager.call("get_board")
if board_variant is Dictionary:
_render_board(board_variant as Dictionary)
if task_manager.has_method("refresh_board"):
task_manager.call("refresh_board")
func hide_panel() -> void:
_is_open = false
_root.visible = false
func is_escape_dismissible() -> bool:
return _is_open
func get_escape_priority() -> int:
return 760
func request_escape_close() -> void:
hide_panel()
func _input(event: InputEvent) -> void:
if not _is_open or get_viewport().is_input_handled():
return
if event.is_action_pressed("ui_cancel"):
hide_panel()
get_viewport().set_input_as_handled()
func _connect_services() -> void:
var event_system := get_node_or_null("/root/EventSystem")
if event_system != null:
event_system.call("connect_event", EventNames.HUD_TASK_BOOK_REQUESTED, _on_task_book_requested, self)
var task_manager := get_node_or_null("/root/TaskManager")
if task_manager != null:
if task_manager.has_signal("board_changed"):
task_manager.connect("board_changed", _on_board_changed)
if task_manager.has_signal("board_failed"):
task_manager.connect("board_failed", _on_board_failed)
func _build_ui() -> void:
_root = Control.new()
_root.name = "TaskBookRoot"
_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_root.visible = false
add_child(_root)
var dim := ColorRect.new()
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
dim.color = Color(0.02, 0.05, 0.09, 0.68)
dim.mouse_filter = Control.MOUSE_FILTER_STOP
dim.gui_input.connect(func(event: InputEvent) -> void:
if event is InputEventMouseButton and (event as InputEventMouseButton).pressed:
hide_panel()
)
_root.add_child(dim)
_panel = PanelContainer.new()
_panel.custom_minimum_size = PANEL_SIZE
_panel.set_anchors_preset(Control.PRESET_CENTER)
_panel.offset_left = -PANEL_SIZE.x * 0.5
_panel.offset_top = -PANEL_SIZE.y * 0.5
_panel.offset_right = PANEL_SIZE.x * 0.5
_panel.offset_bottom = PANEL_SIZE.y * 0.5
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
_panel.add_theme_stylebox_override("panel", _panel_style(Color(0.96, 0.985, 1.0, 1.0), 28))
_root.add_child(_panel)
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 28)
margin.add_theme_constant_override("margin_top", 24)
margin.add_theme_constant_override("margin_right", 28)
margin.add_theme_constant_override("margin_bottom", 24)
_panel.add_child(margin)
var layout := VBoxContainer.new()
layout.add_theme_constant_override("separation", 16)
margin.add_child(layout)
layout.add_child(_build_header())
_status_label = Label.new()
_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_status_label.add_theme_color_override("font_color", MUTED_COLOR)
_status_label.add_theme_font_size_override("font_size", 15)
layout.add_child(_status_label)
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
layout.add_child(scroll)
_content = VBoxContainer.new()
_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_content.add_theme_constant_override("separation", 12)
scroll.add_child(_content)
var footer := Label.new()
footer.text = "[Esc] 关闭任务书"
footer.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
footer.add_theme_color_override("font_color", MUTED_COLOR)
footer.add_theme_font_size_override("font_size", 15)
layout.add_child(footer)
func _build_header() -> Control:
var header := PanelContainer.new()
header.custom_minimum_size = Vector2(0, 96)
header.add_theme_stylebox_override("panel", _panel_style(ACCENT_COLOR, 20))
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 24)
margin.add_theme_constant_override("margin_top", 14)
margin.add_theme_constant_override("margin_right", 16)
margin.add_theme_constant_override("margin_bottom", 14)
header.add_child(margin)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
margin.add_child(row)
var title_box := VBoxContainer.new()
title_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(title_box)
var title := Label.new()
title.text = "鲸镇任务书"
title.add_theme_color_override("font_color", Color.WHITE)
title.add_theme_font_size_override("font_size", 30)
title_box.add_child(title)
var subtitle := Label.new()
subtitle.text = "完成探索、学习与社交任务,领取鲸币奖励"
subtitle.add_theme_color_override("font_color", Color(0.88, 0.95, 1.0, 1.0))
subtitle.add_theme_font_size_override("font_size", 16)
title_box.add_child(subtitle)
var guide := Button.new()
guide.text = "新人引导"
guide.focus_mode = Control.FOCUS_NONE
guide.custom_minimum_size = Vector2(104, 42)
guide.add_theme_stylebox_override("normal", _panel_style(Color(1, 1, 1, 0.18), 14))
guide.add_theme_stylebox_override("hover", _panel_style(Color(1, 1, 1, 0.30), 14))
guide.add_theme_color_override("font_color", Color.WHITE)
guide.pressed.connect(_on_guide_pressed)
row.add_child(guide)
var close := Button.new()
close.text = "关闭"
close.focus_mode = Control.FOCUS_NONE
close.custom_minimum_size = Vector2(84, 42)
close.add_theme_stylebox_override("normal", _panel_style(Color(1, 1, 1, 0.18), 14))
close.add_theme_stylebox_override("hover", _panel_style(Color(1, 1, 1, 0.30), 14))
close.add_theme_color_override("font_color", Color.WHITE)
close.pressed.connect(hide_panel)
row.add_child(close)
return header
func _on_task_book_requested(_data: Dictionary = {}) -> void:
show_panel()
func _on_guide_pressed() -> void:
hide_panel()
var root: Window = get_tree().root
var dialog: Node = root.get_node_or_null(WELCOME_DIALOG_NAME)
if dialog == null:
dialog = WELCOME_DIALOG_SCENE.instantiate()
dialog.name = WELCOME_DIALOG_NAME
root.add_child(dialog)
if dialog.has_method("_showGuide"):
dialog.call_deferred("_showGuide")
func _on_board_changed(board: Dictionary) -> void:
if _is_open:
_render_board(board)
func _on_board_failed(message: String) -> void:
if _is_open and is_instance_valid(_status_label):
_status_label.text = message
_status_label.add_theme_color_override("font_color", Color(0.86, 0.28, 0.24))
func _render_board(board: Dictionary) -> void:
_clear_content()
if board.is_empty():
_status_label.text = "正在读取任务书…"
return
_status_label.add_theme_color_override("font_color", MUTED_COLOR)
var cycle_variant: Variant = board.get("weekly_cycle", {})
var cycle: Dictionary = cycle_variant as Dictionary if cycle_variant is Dictionary else {}
_status_label.text = "周常重置:%s" % _format_reset(str(cycle.get("ends_at", "")))
_add_section("新人主线", "可自由完成,首次购买皮肤为可跳过任务")
_add_tasks(board.get("newbie_tasks", []))
_add_section("本周任务", "四项全部完成后可领取额外 200 鲸币")
_add_tasks(board.get("weekly_tasks", []))
var bonus_variant: Variant = board.get("weekly_bonus", {})
if bonus_variant is Dictionary:
_content.add_child(_build_task_card(bonus_variant as Dictionary))
func _add_section(title_text: String, description: String) -> void:
var section := VBoxContainer.new()
section.add_theme_constant_override("separation", 2)
var title := Label.new()
title.text = title_text
title.add_theme_color_override("font_color", TEXT_COLOR)
title.add_theme_font_size_override("font_size", 23)
section.add_child(title)
var subtitle := Label.new()
subtitle.text = description
subtitle.add_theme_color_override("font_color", MUTED_COLOR)
subtitle.add_theme_font_size_override("font_size", 15)
section.add_child(subtitle)
_content.add_child(section)
func _add_tasks(tasks_variant: Variant) -> void:
if not (tasks_variant is Array):
return
for task_variant: Variant in tasks_variant as Array:
if task_variant is Dictionary:
_content.add_child(_build_task_card(task_variant as Dictionary))
func _build_task_card(task: Dictionary) -> Control:
var completed: bool = bool(task.get("completed", false))
var claimed: bool = bool(task.get("claimed", false))
var optional: bool = bool(task.get("optional", false))
var card := PanelContainer.new()
card.add_theme_stylebox_override("panel", _panel_style(Color(0.90, 0.96, 1.0, 0.92) if completed else Color.WHITE, 18))
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 18)
margin.add_theme_constant_override("margin_top", 14)
margin.add_theme_constant_override("margin_right", 18)
margin.add_theme_constant_override("margin_bottom", 14)
card.add_child(margin)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 14)
margin.add_child(row)
var state := Label.new()
state.custom_minimum_size = Vector2(30, 0)
state.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
state.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
state.text = "" if claimed else ("" if completed else "")
state.add_theme_color_override("font_color", Color(0.17, 0.66, 0.41) if completed else MUTED_COLOR)
state.add_theme_font_size_override("font_size", 24)
row.add_child(state)
var detail := VBoxContainer.new()
detail.size_flags_horizontal = Control.SIZE_EXPAND_FILL
detail.add_theme_constant_override("separation", 4)
row.add_child(detail)
var title := Label.new()
title.text = "%s%s" % [str(task.get("title", "任务")), "(可跳过)" if optional else ""]
title.add_theme_color_override("font_color", TEXT_COLOR)
title.add_theme_font_size_override("font_size", 19)
detail.add_child(title)
var description := Label.new()
description.text = str(task.get("description", ""))
description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
description.add_theme_color_override("font_color", MUTED_COLOR)
description.add_theme_font_size_override("font_size", 15)
detail.add_child(description)
var progress: int = int(task.get("progress", 0))
var target: int = max(1, int(task.get("target", 1)))
var progress_label := Label.new()
progress_label.text = "进度 %d / %d" % [min(progress, target), target]
progress_label.add_theme_color_override("font_color", ACCENT_COLOR)
progress_label.add_theme_font_size_override("font_size", 14)
detail.add_child(progress_label)
var reward := Label.new()
reward.text = "+%d 鲸币" % int(task.get("reward", 0))
reward.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
reward.add_theme_color_override("font_color", COIN_COLOR)
reward.add_theme_font_size_override("font_size", 17)
row.add_child(reward)
var action := Button.new()
action.focus_mode = Control.FOCUS_NONE
action.custom_minimum_size = Vector2(88, 42)
if claimed:
action.text = "已领取"
action.disabled = true
elif completed:
action.text = "领取"
action.pressed.connect(func() -> void:
var task_manager := get_node_or_null("/root/TaskManager")
if task_manager != null and task_manager.has_method("claim_task"):
task_manager.call("claim_task", str(task.get("id", "")))
)
else:
action.text = "进行中"
action.disabled = true
action.add_theme_stylebox_override("normal", _panel_style(ACCENT_COLOR if completed and not claimed else Color(0.80, 0.85, 0.89), 12))
action.add_theme_stylebox_override("hover", _panel_style(Color(0.06, 0.39, 0.71), 12))
action.add_theme_color_override("font_color", Color.WHITE)
row.add_child(action)
return card
func _clear_content() -> void:
for child: Node in _content.get_children():
child.queue_free()
func _format_reset(value: String) -> String:
if value.is_empty():
return "下周一 00:00"
return value.replace("T", " ").left(16)
func _panel_style(color: Color, radius: int) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = color
style.corner_radius_top_left = radius
style.corner_radius_top_right = radius
style.corner_radius_bottom_left = radius
style.corner_radius_bottom_right = radius
style.shadow_color = Color(0.04, 0.13, 0.22, 0.24)
style.shadow_size = 16
style.shadow_offset = Vector2(0, 6)
return style

View File

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

View File

@@ -11,7 +11,7 @@ const GUIDE_PAGES: Array[Dictionary] = [
"image_path": "res://assets/maps/square/v1/props/center_whale_fountain_v2_hd_clean.png", "image_path": "res://assets/maps/square/v1/props/center_whale_fountain_v2_hd_clean.png",
}, },
{ {
"text": "操作提示:\n\n- 按 [color=#ffaa00]E[/color] 键可以与 NPC、公告板和信息板互动。\n- 靠近目标后面向它,再按互动键。\n- 输入框获得焦点时,角色移动会暂停响应。", "text": "操作提示:\n\n- 按 [color=#ffaa00]E[/color] 键可以与 NPC、公告板和信息板互动。\n- 靠近目标后面向它,再按互动键。\n- 按 [color=#ffaa00]Esc[/color] 可以关闭当前界面或结束当前互动。\n- 输入框获得焦点时,角色移动会暂停响应。",
"image_path": "res://assets/maps/square/v1/props/bottom_entrance_right_service_props_v2_hd_clean.png", "image_path": "res://assets/maps/square/v1/props/bottom_entrance_right_service_props_v2_hd_clean.png",
}, },
] ]
@@ -34,10 +34,12 @@ var _guideFooter: HBoxContainer
var _guidePrevButton: Button var _guidePrevButton: Button
var _guideNextButton: Button var _guideNextButton: Button
var _guideDotsContainer: HBoxContainer var _guideDotsContainer: HBoxContainer
var _guideTaskBookButton: Button
var _guideCurrentPage: int = 0 var _guideCurrentPage: int = 0
var _guideTween: Tween var _guideTween: Tween
func _ready() -> void: func _ready() -> void:
add_to_group("whaletown_escape_dismissible")
_disableChatUiMouseInput() _disableChatUiMouseInput()
var closeButton := find_child("CloseButton", true, false) as Button var closeButton := find_child("CloseButton", true, false) as Button
@@ -52,8 +54,20 @@ func _exit_tree() -> void:
_restoreChatUiMouseInput() _restoreChatUiMouseInput()
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if get_viewport().is_input_handled():
return
if event.is_action_pressed("ui_cancel"): if event.is_action_pressed("ui_cancel"):
queue_free() queue_free()
get_viewport().set_input_as_handled()
func is_escape_dismissible() -> bool:
return is_inside_tree()
func get_escape_priority() -> int:
return 980
func request_escape_close() -> void:
queue_free()
func _onClosePressed() -> void: func _onClosePressed() -> void:
queue_free() queue_free()
@@ -62,6 +76,9 @@ func _onStartPressed() -> void:
_showGuide() _showGuide()
func _showGuide() -> void: func _showGuide() -> void:
var taskManager := get_node_or_null("/root/TaskManager")
if taskManager != null and taskManager.has_method("report_activity"):
taskManager.call("report_activity", "guide_opened")
panelContainer.custom_minimum_size = Vector2(720, 560) panelContainer.custom_minimum_size = Vector2(720, 560)
titleLabel.text = "新人引导手册" titleLabel.text = "新人引导手册"
logoContainer.visible = false logoContainer.visible = false
@@ -149,6 +166,12 @@ func _buildGuideUi() -> void:
_guideNextButton.pressed.connect(_onGuideNextPressed) _guideNextButton.pressed.connect(_onGuideNextPressed)
_guideFooter.add_child(_guideNextButton) _guideFooter.add_child(_guideNextButton)
_guideTaskBookButton = _createGuideButton("任务书")
_guideTaskBookButton.name = "TaskBookButton"
_guideTaskBookButton.custom_minimum_size = Vector2(96, 44)
_guideTaskBookButton.pressed.connect(_onGuideTaskBookPressed)
_guideFooter.add_child(_guideTaskBookButton)
func _createGuideButton(text: String) -> Button: func _createGuideButton(text: String) -> Button:
var button := Button.new() var button := Button.new()
button.custom_minimum_size = Vector2(56, 44) button.custom_minimum_size = Vector2(56, 44)
@@ -272,6 +295,12 @@ func _onGuideNextPressed() -> void:
_guideCurrentPage += 1 _guideCurrentPage += 1
_updateGuideUi() _updateGuideUi()
func _onGuideTaskBookPressed() -> void:
var taskBook := get_node_or_null("/root/TaskBookPanel")
if taskBook != null and taskBook.has_method("show_panel"):
taskBook.call("show_panel")
queue_free()
func _disableChatUiMouseInput() -> void: func _disableChatUiMouseInput() -> void:
var currentScene := get_tree().current_scene var currentScene := get_tree().current_scene
if currentScene != null: if currentScene != null:

View File

@@ -20,7 +20,6 @@ const MALL_BADGE_TEXTURE: Texture2D = preload("res://assets/ui/mall/branding/bra
const COIN_TEXTURE: Texture2D = preload("res://assets/ui/mall/branding/branding_whale_coin.png") const COIN_TEXTURE: Texture2D = preload("res://assets/ui/mall/branding/branding_whale_coin.png")
const CATALOG_SCRIPT: Script = preload("res://Config/MallCatalog.gd") const CATALOG_SCRIPT: Script = preload("res://Config/MallCatalog.gd")
const ITEM_CARD_SCRIPT: Script = preload("res://scenes/ui/mall/MallItemCard.gd") const ITEM_CARD_SCRIPT: Script = preload("res://scenes/ui/mall/MallItemCard.gd")
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
var _overlay: ColorRect var _overlay: ColorRect
var _panel: Control var _panel: Control
@@ -39,8 +38,6 @@ var _toastLabel: Label
var _confirmDialog: Control var _confirmDialog: Control
var _confirmLabel: Label var _confirmLabel: Label
var _currencyLabel: Label var _currencyLabel: Label
var _purchaseRequest: HTTPRequest
var _catalogRequest: HTTPRequest
var _categoryButtons: Dictionary = {} var _categoryButtons: Dictionary = {}
var _cards: Array[Button] = [] var _cards: Array[Button] = []
@@ -62,6 +59,7 @@ var _transitionTween: Tween
var _toastTween: Tween var _toastTween: Tween
func _ready() -> void: func _ready() -> void:
add_to_group("whaletown_escape_dismissible")
visible = false visible = false
mouse_filter = Control.MOUSE_FILTER_IGNORE mouse_filter = Control.MOUSE_FILTER_IGNORE
set_process(false) set_process(false)
@@ -82,6 +80,8 @@ func _notification(what: int) -> void:
_position_panel() _position_panel()
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if get_viewport().is_input_handled():
return
if not _isOpen: if not _isOpen:
return return
if event is InputEventKey: if event is InputEventKey:
@@ -120,6 +120,18 @@ func hide_panel() -> void:
func is_panel_open() -> bool: func is_panel_open() -> bool:
return _isOpen return _isOpen
func is_escape_dismissible() -> bool:
return _isOpen
func get_escape_priority() -> int:
return 700
func request_escape_close() -> void:
if is_instance_valid(_confirmDialog) and _confirmDialog.visible:
_hide_confirm_dialog()
return
hide_panel()
func _build_ui() -> void: func _build_ui() -> void:
_overlay = ColorRect.new() _overlay = ColorRect.new()
_overlay.name = "mallDimOverlay" _overlay.name = "mallDimOverlay"
@@ -160,8 +172,6 @@ func _build_ui() -> void:
_build_toast() _build_toast()
_build_confirm_dialog() _build_confirm_dialog()
_build_purchase_request()
_build_catalog_request()
_update_panel_alpha(0.0, 0.0) _update_panel_alpha(0.0, 0.0)
func _build_header() -> Control: func _build_header() -> Control:
@@ -844,20 +854,6 @@ func _format_item_price(item: Dictionary) -> String:
func _is_skin_item(item: Dictionary) -> bool: func _is_skin_item(item: Dictionary) -> bool:
return not str(item.get("skinId", "")).strip_edges().is_empty() return not str(item.get("skinId", "")).strip_edges().is_empty()
func _build_purchase_request() -> void:
_purchaseRequest = HTTPRequest.new()
_purchaseRequest.name = "mallPurchaseRequest"
_purchaseRequest.timeout = 12.0
_purchaseRequest.request_completed.connect(_on_purchase_request_completed)
add_child(_purchaseRequest)
func _build_catalog_request() -> void:
_catalogRequest = HTTPRequest.new()
_catalogRequest.name = "mallCatalogRequest"
_catalogRequest.timeout = 12.0
_catalogRequest.request_completed.connect(_on_catalog_request_completed)
add_child(_catalogRequest)
func _connect_auth_state() -> void: func _connect_auth_state() -> void:
var authManager := get_node_or_null("/root/AuthManager") var authManager := get_node_or_null("/root/AuthManager")
if authManager == null or not authManager.has_signal("auth_state_changed"): if authManager == null or not authManager.has_signal("auth_state_changed"):
@@ -892,10 +888,6 @@ func _current_account_generation() -> int:
return -1 return -1
func _cancel_account_bound_requests() -> void: func _cancel_account_bound_requests() -> void:
if is_instance_valid(_catalogRequest):
_catalogRequest.cancel_request()
if is_instance_valid(_purchaseRequest):
_purchaseRequest.cancel_request()
_catalogRequestAccountGeneration = -1 _catalogRequestAccountGeneration = -1
_purchaseRequestAccountGeneration = -1 _purchaseRequestAccountGeneration = -1
@@ -911,14 +903,6 @@ func _reset_account_bound_state() -> void:
_selectedItem = {} _selectedItem = {}
_hide_confirm_dialog() _hide_confirm_dialog()
func _auth_headers() -> PackedStringArray:
var authManager := get_node_or_null("/root/AuthManager")
var accessToken := str(authManager.call("get_access_token")).strip_edges() if authManager != null and authManager.has_method("get_access_token") else ""
return PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % accessToken,
])
func _submit_backend_purchase(item: Dictionary) -> void: func _submit_backend_purchase(item: Dictionary) -> void:
var authManager := get_node_or_null("/root/AuthManager") var authManager := get_node_or_null("/root/AuthManager")
var accessToken := str(authManager.call("get_access_token")).strip_edges() if authManager != null else "" var accessToken := str(authManager.call("get_access_token")).strip_edges() if authManager != null else ""
@@ -934,43 +918,24 @@ func _submit_backend_purchase(item: Dictionary) -> void:
"item_id": str(item.get("id", "")), "item_id": str(item.get("id", "")),
} }
_purchaseRequestAccountGeneration = _current_account_generation() _purchaseRequestAccountGeneration = _current_account_generation()
var err := _purchaseRequest.request("%s/shop/purchases" % NetworkConfig.get_api_base_url(), _auth_headers(), HTTPClient.METHOD_POST, JSON.stringify(payload)) var api_client := get_node_or_null("/root/ApiClient")
if err != OK: if api_client == null or not api_client.has_method("post_json"):
_purchaseRequestAccountGeneration = -1 _purchaseRequestAccountGeneration = -1
_hide_confirm_dialog() _hide_confirm_dialog()
_emit_event(EventNames.MALL_PURCHASE_FAILED, {"item": item, "reason": "request_failed"}) _emit_event(EventNames.MALL_PURCHASE_FAILED, {"item": item, "reason": "request_failed"})
_show_toast("购买请求发送失败") _show_toast("购买服务不可用")
_update_purchase_button(_selectedItem) _update_purchase_button(_selectedItem)
return
api_client.call("post_json", "/shop/purchases", payload, _on_purchase_request_completed, true)
func _on_purchase_request_completed(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void: func _on_purchase_request_completed(success: bool, response: Dictionary, error_info: Dictionary) -> void:
if _purchaseRequestAccountGeneration != _current_account_generation(): if _purchaseRequestAccountGeneration != _current_account_generation():
return return
_purchaseRequestAccountGeneration = -1 _purchaseRequestAccountGeneration = -1
if result != HTTPRequest.RESULT_SUCCESS: if not success:
_hide_confirm_dialog()
_emit_event(EventNames.MALL_PURCHASE_FAILED, {"item": _selectedItem, "reason": "network_failed"})
_show_toast("购买失败,请稍后再试")
_update_purchase_button(_selectedItem)
return
var json := JSON.new()
if json.parse(body.get_string_from_utf8()) != OK:
_hide_confirm_dialog()
_emit_event(EventNames.MALL_PURCHASE_FAILED, {"item": _selectedItem, "reason": "invalid_response"})
_show_toast("购买响应解析失败")
_update_purchase_button(_selectedItem)
return
var responseVariant: Variant = json.data
if not (responseVariant is Dictionary):
_hide_confirm_dialog()
_emit_event(EventNames.MALL_PURCHASE_FAILED, {"item": _selectedItem, "reason": "invalid_response"})
_show_toast("购买响应格式错误")
_update_purchase_button(_selectedItem)
return
var response: Dictionary = responseVariant
if responseCode < 200 or responseCode >= 300 or not bool(response.get("success", true)):
_hide_confirm_dialog() _hide_confirm_dialog()
_emit_event(EventNames.MALL_PURCHASE_FAILED, {"item": _selectedItem, "reason": "backend_rejected"}) _emit_event(EventNames.MALL_PURCHASE_FAILED, {"item": _selectedItem, "reason": "backend_rejected"})
_show_toast(str(response.get("message", "购买失败"))) _show_toast(str(error_info.get("message", "购买失败")))
_update_purchase_button(_selectedItem) _update_purchase_button(_selectedItem)
return return
var dataVariant: Variant = response.get("data", {}) var dataVariant: Variant = response.get("data", {})
@@ -1015,37 +980,19 @@ func _fetch_catalog() -> void:
_walletError = "" _walletError = ""
_update_currency() _update_currency()
_catalogRequestAccountGeneration = _current_account_generation() _catalogRequestAccountGeneration = _current_account_generation()
var err := _catalogRequest.request("%s/shop/catalog" % NetworkConfig.get_api_base_url(), _auth_headers(), HTTPClient.METHOD_GET, "") var api_client := get_node_or_null("/root/ApiClient")
if err != OK: if api_client == null or not api_client.has_method("get_json"):
_catalogRequestAccountGeneration = -1 _catalogRequestAccountGeneration = -1
_catalogLoading = false _apply_catalog_error("商城服务不可用")
_catalogLoaded = false return
_catalogError = "商城数据请求发送失败" api_client.call("get_json", "/shop/catalog", _on_catalog_request_completed, true)
_walletLoading = false
_walletLoaded = false
_walletError = _catalogError
_update_currency()
_render_grid([])
push_warning("MallPanel: 商城数据请求发送失败: %s" % error_string(err))
func _on_catalog_request_completed(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void: func _on_catalog_request_completed(success: bool, response: Dictionary, error_info: Dictionary) -> void:
if _catalogRequestAccountGeneration != _current_account_generation(): if _catalogRequestAccountGeneration != _current_account_generation():
return return
_catalogRequestAccountGeneration = -1 _catalogRequestAccountGeneration = -1
if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300: if not success:
_apply_catalog_error("商城数据读取失败") _apply_catalog_error(str(error_info.get("message", "商城数据读取失败")))
return
var json := JSON.new()
if json.parse(body.get_string_from_utf8()) != OK:
_apply_catalog_error("商城响应解析失败")
return
var responseVariant: Variant = json.data
if not (responseVariant is Dictionary):
_apply_catalog_error("商城响应格式错误")
return
var response: Dictionary = responseVariant
if not bool(response.get("success", true)):
_apply_catalog_error(str(response.get("message", "商城数据读取失败")))
return return
var dataVariant: Variant = response.get("data", {}) var dataVariant: Variant = response.get("data", {})
if not (dataVariant is Dictionary): if not (dataVariant is Dictionary):

21
scripts/audit_asset_size.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
# Keep single source assets below 20 MiB by default. Override in CI when a
# stricter product budget is available.
limit_bytes="${ASSET_LIMIT_BYTES:-20971520}"
status=0
while IFS= read -r -d '' file; do
size=$(wc -c < "$file")
if (( size > limit_bytes )); then
printf '%s\t%s\n' "$size" "$file"
status=1
fi
done < <(git ls-files -z -- 'assets/**')
if (( status != 0 )); then
printf '发现超过 %s 字节的受版本控制资源;请压缩、外置或使用 Git LFS。\n' "$limit_bytes" >&2
fi
exit "$status"

View File

@@ -0,0 +1,72 @@
extends SceneTree
const TILESET_PATH: String = "res://assets/maps/work_zone/v13/tilesets/work_zone_road_tileset.tres"
const TEXTURE_DIR: String = "res://assets/maps/work_zone/v13/tilesets"
func _init() -> void:
var tileset := load(TILESET_PATH) as TileSet
if tileset == null:
push_error("无法加载 TileSet: %s" % TILESET_PATH)
quit(1)
return
var candidates := _load_candidates()
var replaced := 0
for source_index in tileset.get_source_count():
var source_id := tileset.get_source_id(source_index)
var atlas_source := tileset.get_source(source_id) as TileSetAtlasSource
if atlas_source == null or atlas_source.texture == null:
continue
var source_hash := _texture_hash(atlas_source.texture)
var replacement: Texture2D = candidates.get(source_hash)
if replacement == null:
_write_audit_image(source_id, atlas_source.texture)
push_error("未找到匹配的外置纹理source=%d hash=%s" % [source_id, source_hash])
quit(2)
return
atlas_source.texture = replacement
replaced += 1
var error := ResourceSaver.save(tileset, TILESET_PATH)
if error != OK:
push_error("保存 TileSet 失败: %s" % error_string(error))
quit(3)
return
print("已外置 %d 个 TileSet 纹理源" % replaced)
quit()
func _load_candidates() -> Dictionary:
var candidates: Dictionary = {}
for file_name in DirAccess.get_files_at(TEXTURE_DIR):
if not file_name.ends_with(".png"):
continue
var path := "%s/%s" % [TEXTURE_DIR, file_name]
var texture := load(path) as Texture2D
if texture != null:
candidates[_bytes_hash(FileAccess.get_file_as_bytes(path))] = texture
return candidates
func _texture_hash(texture: Texture2D) -> String:
var image := texture.get_image()
if image == null:
return ""
return _bytes_hash(image.save_png_to_buffer())
func _bytes_hash(bytes: PackedByteArray) -> String:
var context := HashingContext.new()
var start_error := context.start(HashingContext.HASH_SHA256)
if start_error != OK:
return ""
var update_error := context.update(bytes)
if update_error != OK:
return ""
return context.finish().hex_encode()
func _write_audit_image(source_id: int, texture: Texture2D) -> void:
var directory := ProjectSettings.globalize_path("res://build/tileset-audit")
DirAccess.make_dir_recursive_absolute(directory)
var image := texture.get_image()
if image == null:
return
var path := "%s/source_%d.png" % [directory, source_id]
var error := image.save_png(path)
if error == OK:
print("已写出待核对纹理: %s (%dx%d)" % [path, image.get_width(), image.get_height()])

View File

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

View File

@@ -0,0 +1,40 @@
extends SceneTree
const SecureSessionStore = preload("res://_Core/security/SecureSessionStore.gd")
const TEST_PATH: String = "user://whaletown-secure-session-test.json"
const TEST_TOKEN: String = "refresh-token-test-value"
func _init() -> void:
var store: RefCounted = SecureSessionStore.new()
store.call("clear", TEST_PATH)
var saved := bool(store.call("save_refresh_token", TEST_PATH, TEST_TOKEN))
if not saved:
print("安全持久化在当前平台不可用,已按仅内存策略跳过")
quit()
return
var loaded := str(store.call("load_refresh_token", TEST_PATH))
if loaded != TEST_TOKEN:
push_error("安全会话存储往返测试失败")
quit(1)
return
var payload_variant: Variant = JSON.parse_string(FileAccess.get_file_as_string(TEST_PATH))
if not (payload_variant is Dictionary):
push_error("安全会话存储测试载荷解析失败")
quit(1)
return
var payload: Dictionary = payload_variant
payload["mac"] = Marshalls.raw_to_base64(PackedByteArray([1, 2, 3, 4]))
var tampered_file := FileAccess.open(TEST_PATH, FileAccess.WRITE)
if tampered_file == null:
push_error("安全会话存储篡改测试无法写入")
quit(1)
return
tampered_file.store_string(JSON.stringify(payload))
tampered_file.close()
var tampered_token := str(store.call("load_refresh_token", TEST_PATH))
if not tampered_token.is_empty() or FileAccess.file_exists(TEST_PATH):
push_error("安全会话存储未拒绝被篡改载荷")
quit(1)
return
print("安全会话存储往返与篡改检测测试通过")
quit()

View File

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