forked from xiangwang25/whale-town-front-v2
Compare commits
5 Commits
feature-ta
...
refactor-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc872fe8af | ||
|
|
5d2339a29b | ||
|
|
ce896fc1e0 | ||
|
|
d60444d2eb | ||
|
|
e3accf6b71 |
13
.gitattributes
vendored
13
.gitattributes
vendored
@@ -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
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ 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_TASK_BOOK_REQUESTED = "hud_task_book_requested"
|
||||||
|
const HUD_ADMIN_TEST_LAB_REQUESTED = "hud_admin_test_lab_requested"
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# 商城事件
|
# 商城事件
|
||||||
|
|||||||
54
_Core/chat/ChatMessageCodec.gd
Normal file
54
_Core/chat/ChatMessageCodec.gd
Normal 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)
|
||||||
1
_Core/chat/ChatMessageCodec.gd.uid
Normal file
1
_Core/chat/ChatMessageCodec.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dao88q6dkwueb
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -18,32 +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 _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()
|
||||||
|
|
||||||
@@ -213,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)
|
|
||||||
_active_requests.append(request)
|
|
||||||
var requestGeneration := _account_generation
|
|
||||||
|
|
||||||
request.request_completed.connect(func(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
||||||
_active_requests.erase(request)
|
|
||||||
request.queue_free()
|
|
||||||
if accountBound and requestGeneration != _account_generation:
|
|
||||||
return
|
return
|
||||||
_handle_json_response(result, response_code, body, callback)
|
var request_generation := _account_generation
|
||||||
|
api_client.call(
|
||||||
|
"request_json",
|
||||||
|
endpoint,
|
||||||
|
payload,
|
||||||
|
func(success: bool, response: Dictionary, error_info: Dictionary) -> void:
|
||||||
|
if accountBound and request_generation != _account_generation:
|
||||||
|
return
|
||||||
|
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", "登录失败")))
|
||||||
@@ -415,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():
|
||||||
if is_authenticated():
|
return
|
||||||
_session_generation += 1
|
_session_generation += 1
|
||||||
_account_generation += 1
|
_account_generation += 1
|
||||||
if emit_cached_state and is_authenticated():
|
if emit_cached_state:
|
||||||
call_deferred("_emit_cached_auth_state")
|
call_deferred("refresh_session")
|
||||||
|
|
||||||
func _emit_cached_auth_state() -> void:
|
func _migrate_legacy_cached_session() -> void:
|
||||||
auth_state_changed.emit(true, get_current_user())
|
if not FileAccess.file_exists(LEGACY_AUTH_CONFIG_PATH):
|
||||||
|
return
|
||||||
|
var config := ConfigFile.new()
|
||||||
|
if config.load(LEGACY_AUTH_CONFIG_PATH) == OK:
|
||||||
|
var legacy_refresh_token := str(config.get_value("auth", "refresh_token", "")).strip_edges()
|
||||||
|
if not legacy_refresh_token.is_empty():
|
||||||
|
_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 = ""
|
||||||
@@ -456,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()
|
||||||
@@ -503,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
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
)
|
|
||||||
|
|
||||||
var headers := PackedStringArray(["Content-Type: application/json"])
|
|
||||||
var token := _get_auth_token()
|
|
||||||
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:
|
if not success:
|
||||||
_emit_chat_error(str(response.get("message", "咖啡店陪聊请求失败")))
|
_emit_chat_error(str(error_info.get("message", "咖啡店陪聊请求失败")))
|
||||||
return
|
return
|
||||||
|
|
||||||
callback.call(response)
|
callback.call(response)
|
||||||
|
, method, true, REQUEST_TIMEOUT)
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1061,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": "",
|
||||||
@@ -1153,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, {
|
||||||
@@ -1172,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()
|
||||||
|
|
||||||
@@ -1263,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):
|
||||||
@@ -1342,40 +1310,6 @@ func _current_user_id() -> String:
|
|||||||
return str((user_variant as Dictionary).get("id", ""))
|
return str((user_variant as Dictionary).get("id", ""))
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# 解析聊天消息时间戳(兼容 unix 秒 / ISO 8601 字符串)
|
|
||||||
func _parse_chat_timestamp_to_unix(timestamp_raw: Variant) -> float:
|
|
||||||
if typeof(timestamp_raw) == TYPE_INT or typeof(timestamp_raw) == TYPE_FLOAT:
|
|
||||||
var ts := float(timestamp_raw)
|
|
||||||
return ts if ts > 0.0 else Time.get_unix_time_from_system()
|
|
||||||
|
|
||||||
var ts_str := str(timestamp_raw)
|
|
||||||
if ts_str.strip_edges().is_empty():
|
|
||||||
return Time.get_unix_time_from_system()
|
|
||||||
|
|
||||||
# 纯数字字符串(必须整串都是数字/小数点,避免把 ISO 字符串前缀 "2026" 误判成时间戳)
|
|
||||||
var numeric_regex := RegEx.new()
|
|
||||||
numeric_regex.compile("^\\s*-?\\d+(?:\\.\\d+)?\\s*$")
|
|
||||||
if numeric_regex.search(ts_str) != null:
|
|
||||||
var ts_num := float(ts_str)
|
|
||||||
return ts_num if ts_num > 0.0 else Time.get_unix_time_from_system()
|
|
||||||
|
|
||||||
# ISO 8601: 2026-01-19T15:15:43.930Z
|
|
||||||
var regex := RegEx.new()
|
|
||||||
regex.compile("(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})")
|
|
||||||
var result := regex.search(ts_str)
|
|
||||||
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)
|
|
||||||
|
|
||||||
# 处理位置更新成功
|
# 处理位置更新成功
|
||||||
func _handle_position_updated(data: Dictionary) -> void:
|
func _handle_position_updated(data: Dictionary) -> void:
|
||||||
var stream: String = data.get("stream", "")
|
var stream: String = data.get("stream", "")
|
||||||
|
|||||||
@@ -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", "家具位置保存失败"))
|
||||||
|
if int(error_info.get("response_code", 0)) == 409:
|
||||||
|
_handle_revision_conflict(error_info, message)
|
||||||
|
return
|
||||||
|
_retry_or_fail(message)
|
||||||
return
|
return
|
||||||
|
|
||||||
var savedItem: Dictionary = (_inFlight.get("item", {}) as Dictionary).duplicate(true)
|
var saved_item: Dictionary = (_in_flight.get("item", {}) as Dictionary).duplicate(true)
|
||||||
var json := JSON.new()
|
var data_variant: Variant = response.get("data", {})
|
||||||
if json.parse(body.get_string_from_utf8()) == OK and json.data is Dictionary:
|
if data_variant is Dictionary:
|
||||||
var response: Dictionary = json.data
|
saved_item = (data_variant as Dictionary).duplicate(true)
|
||||||
if not bool(response.get("success", true)):
|
var accepted_revision := int(saved_item.get("layout_revision", _in_flight.get("mutation_revision", _confirmed_revision)))
|
||||||
_retry_or_fail(str(response.get("message", "家具位置保存失败")))
|
_confirmed_revision = maxi(_confirmed_revision, accepted_revision)
|
||||||
return
|
_in_flight.clear()
|
||||||
var dataVariant: Variant = response.get("data", {})
|
decor_save_succeeded.emit(saved_item)
|
||||||
if dataVariant is Dictionary:
|
|
||||||
savedItem = (dataVariant as Dictionary).duplicate(true)
|
|
||||||
|
|
||||||
_inFlight.clear()
|
|
||||||
_requestGeneration = -1
|
|
||||||
decor_save_succeeded.emit(savedItem)
|
|
||||||
_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
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ 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 _next_destination_id: String = "" # 快速传送目标,用于发现登记
|
||||||
|
var _room_visit_context: Dictionary = {} # 访客房间的房主与返回位置
|
||||||
|
|
||||||
# 场景路径映射表
|
# 场景路径映射表
|
||||||
# 将场景名称映射到实际的文件路径
|
# 将场景名称映射到实际的文件路径
|
||||||
@@ -203,6 +204,39 @@ func get_next_destination_id() -> String:
|
|||||||
_next_destination_id = ""
|
_next_destination_id = ""
|
||||||
return 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
|
||||||
|
|
||||||
# ============ 场景注册方法 ============
|
# ============ 场景注册方法 ============
|
||||||
|
|
||||||
# 注册新场景
|
# 注册新场景
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const DEFAULT_SETTINGS: Dictionary = {
|
|||||||
"allow_nearby_private": true,
|
"allow_nearby_private": true,
|
||||||
"allow_nearby_friend_requests": true,
|
"allow_nearby_friend_requests": true,
|
||||||
"allow_nearby_profile": true,
|
"allow_nearby_profile": true,
|
||||||
|
"room_visit_policy": "friends",
|
||||||
"mute_ui_sfx": false,
|
"mute_ui_sfx": false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,38 @@ func open_private_chat(user_id: String, username: String) -> void:
|
|||||||
event_system.call("emit_event", EventNames.CHAT_PRIVATE_TARGET_SELECTED, {"userId": user_id, "username": username})
|
event_system.call("emit_event", EventNames.CHAT_PRIVATE_TARGET_SELECTED, {"userId": user_id, "username": username})
|
||||||
_close_profile()
|
_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:
|
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:
|
_request_post("/world/travel-destinations/%s/travel" % destination_id.uri_encode(), {}, func(success: bool, response: Dictionary, error: Dictionary) -> void:
|
||||||
if not success:
|
if not success:
|
||||||
@@ -218,6 +250,8 @@ func _render_profile() -> void:
|
|||||||
actions.add_theme_constant_override("separation", 8)
|
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: open_private_chat(user_id, nickname)))
|
||||||
actions.add_child(_button("加好友", func() -> void: request_friend(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)
|
_profile_content.add_child(actions)
|
||||||
var safety := HBoxContainer.new()
|
var safety := HBoxContainer.new()
|
||||||
safety.add_theme_constant_override("separation", 8)
|
safety.add_theme_constant_override("separation", 8)
|
||||||
@@ -427,6 +461,14 @@ func _current_user_id() -> String:
|
|||||||
func _area_label(map_id: String) -> String:
|
func _area_label(map_id: String) -> String:
|
||||||
return {"whale_port": "中心广场", "work_zone": "打工区", "whale_cafe": "鲸鱼咖啡馆", "personal_space": "个人空间"}.get(map_id, map_id)
|
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]:
|
func _interest_catalog() -> Array[Dictionary]:
|
||||||
return [
|
return [
|
||||||
{"id": "ai", "label": "AI/大模型"}, {"id": "programming", "label": "编程开发"},
|
{"id": "ai", "label": "AI/大模型"}, {"id": "programming", "label": "编程开发"},
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ signal reward_claimed(task_id: String, wallet: Dictionary)
|
|||||||
var _board: Dictionary = {}
|
var _board: Dictionary = {}
|
||||||
var _loading_board: bool = false
|
var _loading_board: bool = false
|
||||||
var _account_generation: int = -1
|
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:
|
func _ready() -> void:
|
||||||
var auth_manager := get_node_or_null("/root/AuthManager")
|
var auth_manager := get_node_or_null("/root/AuthManager")
|
||||||
@@ -37,14 +42,34 @@ func refresh_board() -> void:
|
|||||||
func report_activity(activity: String, target_id: String = "") -> void:
|
func report_activity(activity: String, target_id: String = "") -> void:
|
||||||
if not _is_authenticated():
|
if not _is_authenticated():
|
||||||
return
|
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()
|
var api := _api_client()
|
||||||
if api == null:
|
if api == null:
|
||||||
return
|
return
|
||||||
var payload := {"activity": activity}
|
var payload := {
|
||||||
if not target_id.strip_edges().is_empty():
|
"activity": activity,
|
||||||
payload["target_id"] = target_id.strip_edges()
|
"nonce": _create_activity_nonce(),
|
||||||
var request_generation := _current_account_generation()
|
"occurred_at": Time.get_datetime_string_from_system(true, false),
|
||||||
api.call("post_json", "/tasks/activities", payload, Callable(self, "_on_activity_response").bind(request_generation), true)
|
}
|
||||||
|
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:
|
func claim_task(task_id: String) -> void:
|
||||||
if task_id.strip_edges().is_empty() or not _is_authenticated():
|
if task_id.strip_edges().is_empty() or not _is_authenticated():
|
||||||
@@ -63,6 +88,7 @@ func _refresh_after_ready() -> void:
|
|||||||
func _on_auth_state_changed(is_authenticated: bool, _user: Dictionary) -> void:
|
func _on_auth_state_changed(is_authenticated: bool, _user: Dictionary) -> void:
|
||||||
if not is_authenticated:
|
if not is_authenticated:
|
||||||
_board.clear()
|
_board.clear()
|
||||||
|
_recent_activity_at.clear()
|
||||||
_loading_board = false
|
_loading_board = false
|
||||||
_account_generation = -1
|
_account_generation = -1
|
||||||
board_changed.emit({})
|
board_changed.emit({})
|
||||||
@@ -78,10 +104,33 @@ func _on_board_response(success: bool, response: Dictionary, error_info: Diction
|
|||||||
return
|
return
|
||||||
_apply_board_response(response)
|
_apply_board_response(response)
|
||||||
|
|
||||||
func _on_activity_response(success: bool, response: Dictionary, _error_info: Dictionary, request_generation: int) -> void:
|
func _on_activity_response(
|
||||||
if not success or request_generation != _current_account_generation():
|
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
|
return
|
||||||
|
if success:
|
||||||
_apply_board_response(response)
|
_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:
|
func _on_claim_response(success: bool, response: Dictionary, error_info: Dictionary, task_id: String, request_generation: int) -> void:
|
||||||
if request_generation != _current_account_generation():
|
if request_generation != _current_account_generation():
|
||||||
@@ -126,3 +175,7 @@ func _is_authenticated() -> bool:
|
|||||||
func _current_account_generation() -> int:
|
func _current_account_generation() -> int:
|
||||||
var auth_manager := get_node_or_null("/root/AuthManager")
|
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
|
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()
|
||||||
|
|||||||
135
_Core/managers/TestLabManager.gd
Normal file
135
_Core/managers/TestLabManager.gd
Normal 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
|
||||||
1
_Core/managers/TestLabManager.gd.uid
Normal file
1
_Core/managers/TestLabManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cntd1faiax3br
|
||||||
92
_Core/room_decor/RoomDecorCollisionRegistry.gd
Normal file
92
_Core/room_decor/RoomDecorCollisionRegistry.gd
Normal 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
|
||||||
1
_Core/room_decor/RoomDecorCollisionRegistry.gd.uid
Normal file
1
_Core/room_decor/RoomDecorCollisionRegistry.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://djp28p0cx377d
|
||||||
60
_Core/room_decor/RoomDecorEditHistory.gd
Normal file
60
_Core/room_decor/RoomDecorEditHistory.gd
Normal 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
|
||||||
1
_Core/room_decor/RoomDecorEditHistory.gd.uid
Normal file
1
_Core/room_decor/RoomDecorEditHistory.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bvmpisyit53q0
|
||||||
130
_Core/security/SecureSessionStore.gd
Normal file
130
_Core/security/SecureSessionStore.gd
Normal 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
|
||||||
1
_Core/security/SecureSessionStore.gd.uid
Normal file
1
_Core/security/SecureSessionStore.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bqd3tfgvlhj8r
|
||||||
File diff suppressed because one or more lines are too long
@@ -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=""
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ SocialManager="*res://_Core/managers/SocialManager.gd"
|
|||||||
InteractionManager="*res://_Core/managers/InteractionManager.gd"
|
InteractionManager="*res://_Core/managers/InteractionManager.gd"
|
||||||
TaskManager="*res://_Core/managers/TaskManager.gd"
|
TaskManager="*res://_Core/managers/TaskManager.gd"
|
||||||
TaskBookPanel="*res://scenes/ui/TaskBookPanel.gd"
|
TaskBookPanel="*res://scenes/ui/TaskBookPanel.gd"
|
||||||
|
TestLabManager="*res://_Core/managers/TestLabManager.gd"
|
||||||
|
AdminTestLabPanel="*res://scenes/ui/AdminTestLabPanel.gd"
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,11 @@ 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)
|
||||||
|
|||||||
685
scenes/ui/AdminTestLabPanel.gd
Normal file
685
scenes/ui/AdminTestLabPanel.gd
Normal 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)
|
||||||
1
scenes/ui/AdminTestLabPanel.gd.uid
Normal file
1
scenes/ui/AdminTestLabPanel.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cma7is74nvb3u
|
||||||
@@ -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
|
||||||
@@ -686,7 +683,6 @@ func _ready() -> void:
|
|||||||
_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()
|
||||||
@@ -727,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
|
||||||
@@ -749,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 = ""
|
||||||
|
|
||||||
@@ -1402,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("该账号已完成注册角色生成,正在进入小镇...")
|
||||||
@@ -1427,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
|
||||||
@@ -1443,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(),
|
_skin_generation_poll_in_flight = false
|
||||||
HTTPClient.METHOD_GET
|
_set_workshop_generation_status("角色生成服务不可用")
|
||||||
|
return
|
||||||
|
api_client.call(
|
||||||
|
"request_json",
|
||||||
|
endpoint,
|
||||||
|
{},
|
||||||
|
_on_skin_generation_poll_completed,
|
||||||
|
HTTPClient.METHOD_GET,
|
||||||
|
true,
|
||||||
|
SKIN_GENERATION_REQUEST_TIMEOUT
|
||||||
)
|
)
|
||||||
if err != OK:
|
|
||||||
_skin_generation_poll_in_flight = false
|
|
||||||
_set_workshop_generation_status("查询生成状态失败:%s" % error_string(err))
|
|
||||||
|
|
||||||
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():
|
||||||
@@ -1548,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:
|
||||||
|
|||||||
@@ -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,7 +24,6 @@ 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
|
||||||
|
|
||||||
@@ -124,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:
|
||||||
@@ -222,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", {})
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -186,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:
|
||||||
@@ -252,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", {})
|
||||||
|
|||||||
@@ -371,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:
|
||||||
@@ -417,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -436,6 +436,8 @@ func _on_travel_authorized(success: bool, destination: Dictionary) -> void:
|
|||||||
_render_markers()
|
_render_markers()
|
||||||
return
|
return
|
||||||
set_panel_open(false)
|
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_destination_id(str(destination.get("id", "")))
|
||||||
SceneManager.set_next_scene_position(position)
|
SceneManager.set_next_scene_position(position)
|
||||||
SceneManager.change_scene(sceneName)
|
SceneManager.change_scene(sceneName)
|
||||||
|
|||||||
@@ -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(406, 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
|
||||||
@@ -26,6 +26,8 @@ var _avatarPanel: PanelContainer
|
|||||||
var _walletRefreshTimer: Timer
|
var _walletRefreshTimer: Timer
|
||||||
var _taskShortcutButton: Button
|
var _taskShortcutButton: Button
|
||||||
var _taskRewardBadge: Label
|
var _taskRewardBadge: Label
|
||||||
|
var _shortcutRow: HBoxContainer
|
||||||
|
var _adminShortcutButton: Button
|
||||||
|
|
||||||
var _currentUsername: String = "玩家"
|
var _currentUsername: String = "玩家"
|
||||||
var _walletBalance: int = 0
|
var _walletBalance: int = 0
|
||||||
@@ -39,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()
|
||||||
@@ -94,6 +97,7 @@ 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)
|
||||||
@@ -121,6 +125,7 @@ func _build_shortcut_bar() -> PanelContainer:
|
|||||||
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("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
|
||||||
|
|
||||||
@@ -296,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", {})
|
||||||
@@ -306,11 +313,13 @@ 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({})
|
_on_task_board_changed({})
|
||||||
|
_refresh_admin_shortcut()
|
||||||
|
|
||||||
func _on_task_board_changed(board: Dictionary) -> void:
|
func _on_task_board_changed(board: Dictionary) -> void:
|
||||||
if not is_instance_valid(_taskShortcutButton):
|
if not is_instance_valid(_taskShortcutButton):
|
||||||
@@ -482,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:
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const DEFAULT_SETTINGS: Dictionary = {
|
|||||||
"allow_nearby_private": true,
|
"allow_nearby_private": true,
|
||||||
"allow_nearby_friend_requests": true,
|
"allow_nearby_friend_requests": true,
|
||||||
"allow_nearby_profile": true,
|
"allow_nearby_profile": true,
|
||||||
|
"room_visit_policy": "friends",
|
||||||
"mute_ui_sfx": false,
|
"mute_ui_sfx": false,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,6 +389,9 @@ func _render_chat_content() -> void:
|
|||||||
_create_toggle_row("chat", "允许附近私聊", "allow_nearby_private", "附近玩家按 E 可以发起悄悄话"),
|
_create_toggle_row("chat", "允许附近私聊", "allow_nearby_private", "附近玩家按 E 可以发起悄悄话"),
|
||||||
_create_toggle_row("account", "允许好友申请", "allow_nearby_friend_requests", "附近玩家可通过互动列表发送好友申请"),
|
_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("操作说明", [
|
||||||
@@ -526,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)
|
||||||
|
|||||||
@@ -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] = []
|
||||||
@@ -175,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:
|
||||||
@@ -859,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"):
|
||||||
@@ -907,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
|
||||||
|
|
||||||
@@ -926,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 ""
|
||||||
@@ -949,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", {})
|
||||||
@@ -1030,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
21
scripts/audit_asset_size.sh
Executable 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"
|
||||||
72
scripts/externalize_work_zone_tileset.gd
Normal file
72
scripts/externalize_work_zone_tileset.gd
Normal 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()])
|
||||||
1
scripts/externalize_work_zone_tileset.gd.uid
Normal file
1
scripts/externalize_work_zone_tileset.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cgcum8jwl147u
|
||||||
40
scripts/test_secure_session_store.gd
Normal file
40
scripts/test_secure_session_store.gd
Normal 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()
|
||||||
1
scripts/test_secure_session_store.gd.uid
Normal file
1
scripts/test_secure_session_store.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://ct3bimrlbknt3
|
||||||
Reference in New Issue
Block a user