diff --git a/.gitignore b/.gitignore index 8d6dff3..c688736 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,8 @@ Godot/ *.log *.tmp *.temp +/tmp/ +/.playwright-cli/ # Build outputs build/ @@ -51,6 +53,7 @@ dist/ # Credentials and local secrets secrets/ +/.secrets/ *.pem *.key *.p12 diff --git a/DESKTOP_BUILD.md b/DESKTOP_BUILD.md new file mode 100644 index 0000000..097a16a --- /dev/null +++ b/DESKTOP_BUILD.md @@ -0,0 +1,37 @@ +# WhaleTown 桌面客户端构建 + +桌面客户端和 Web 客户端使用同一套 Godot 项目、正式登录接口和 WebSocket 服务。用户下载客户端后直接进入登录页,账号数据仍由线上服务保存,不写入项目目录。 + +## 构建目标 + +- macOS:`build/desktop/macos/WhaleTown.app`,通用架构,支持 Apple Silicon 和 Intel Mac。 +- Windows:`build/desktop/windows/WhaleTown.exe`,64 位桌面版本。 + +## 环境要求 + +1. 安装与项目一致的 Godot `4.6.2.stable`。 +2. 在 Godot 编辑器的导出模板管理器中安装 macOS 和 Windows Desktop 模板。 +3. 默认网络配置位于 `Config/game_config.json`,发布前确认 `production` 地址指向正式服务。 + +## 构建命令 + +在 `whale-town-front-v2` 目录执行: + +```bash +./scripts/build_desktop.sh macos +./scripts/build_desktop.sh windows +./scripts/build_desktop.sh all +./scripts/build_desktop.sh release +``` + +`release` 会同时构建两个平台,生成 ZIP,并在 `build/desktop/release/SHA256SUMS.txt` 写入校验值。也可以通过 `GODOT_BIN=/path/to/Godot` 和 `WHALETOWN_VERSION=1.0.0` 指定 Godot 路径及发布版本。 + +Windows 构建可以从 macOS 交叉导出,但最终发布前仍应在真实 Windows 机器上启动一次,确认显卡、字体、文件选择器和网络连接正常。 + +## 面向用户发布 + +首版可以直接将 `build/desktop/release` 中的 ZIP 供内测下载。正式公开下载前还需要: + +- macOS 使用 Apple Developer ID 签名和 notarization,避免 Gatekeeper 拦截。 +- Windows 使用代码签名证书,降低 SmartScreen 警告。 +- 将客户端压缩包上传到下载站,并提供版本号、更新日期和 SHA-256 校验值。 diff --git a/_Core/EventNames.gd b/_Core/EventNames.gd index 54450fa..0138491 100644 --- a/_Core/EventNames.gd +++ b/_Core/EventNames.gd @@ -19,6 +19,12 @@ const REMOTE_PLAYER_JOINED = "remote_player_joined" const REMOTE_PLAYER_LEFT = "remote_player_left" const REMOTE_PLAYER_POSITION_UPDATED = "remote_player_position_updated" const REMOTE_PLAYERS_SNAPSHOT_READY = "remote_players_snapshot_ready" +const NPC_SNAPSHOT_READY = "npc_snapshot_ready" +const NPC_ACTION_STARTED = "npc_action_started" +const NPC_ACTION_COMPLETED = "npc_action_completed" +const NPC_SPOKE = "npc_spoke" +const NPC_INTERACTION_ERROR = "npc_interaction_error" +const NPC_CONVERSATION = "npc_conversation" const REMOTE_SKIN_READY = "remote_skin_ready" const REMOTE_SKIN_FAILED = "remote_skin_failed" const PLAYER_HEALTH_CHANGED = "player_health_changed" diff --git a/_Core/managers/ApiClient.gd b/_Core/managers/ApiClient.gd index 9e36164..67cbf61 100644 --- a/_Core/managers/ApiClient.gd +++ b/_Core/managers/ApiClient.gd @@ -13,6 +13,16 @@ const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd") const REQUEST_TIMEOUT: float = 12.0 var _activeRequests: Array[HTTPRequest] = [] +var _pendingAuthRetries: Array[Dictionary] = [] +var _awaitingSessionRefresh: bool = false + +func _ready() -> void: + call_deferred("_connect_auth_manager") + +func _connect_auth_manager() -> void: + var authManager := get_node_or_null("/root/AuthManager") + if authManager != null and authManager.has_signal("session_refresh_completed") and not authManager.is_connected("session_refresh_completed", _on_session_refresh_completed): + authManager.connect("session_refresh_completed", _on_session_refresh_completed) func _exit_tree() -> void: for request in _activeRequests: @@ -20,6 +30,7 @@ func _exit_tree() -> void: request.cancel_request() request.queue_free() _activeRequests.clear() + _pendingAuthRetries.clear() func get_json(endpoint: String, callback: Callable, authenticated: bool = true) -> void: request_json(endpoint, {}, callback, HTTPClient.METHOD_GET, authenticated) @@ -34,6 +45,9 @@ func put_json(endpoint: String, payload: Dictionary, callback: Callable, authent request_json(endpoint, payload, callback, HTTPClient.METHOD_PUT, authenticated) func request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_GET, authenticated: bool = true) -> void: + _request_json(endpoint, payload, callback, method, authenticated, 0) + +func _request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int, authenticated: bool, authRetryCount: int) -> void: var request := HTTPRequest.new() request.timeout = REQUEST_TIMEOUT add_child(request) @@ -42,7 +56,7 @@ func request_json(endpoint: String, payload: Dictionary, callback: Callable, met request.request_completed.connect(func(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void: _activeRequests.erase(request) request.queue_free() - _handle_response(endpoint, result, responseCode, body, callback) + _handle_response(endpoint, result, responseCode, body, callback, payload, method, authenticated, authRetryCount) ) var url := "%s%s" % [NetworkConfig.get_api_base_url(), endpoint] @@ -64,13 +78,27 @@ func _headers(authenticated: bool) -> PackedStringArray: headers.append("Authorization: Bearer %s" % accessToken) return headers -func _handle_response(endpoint: String, result: int, responseCode: int, body: PackedByteArray, callback: Callable) -> void: +func _handle_response( + endpoint: String, + result: int, + responseCode: int, + body: PackedByteArray, + callback: Callable, + payload: Dictionary, + method: int, + authenticated: bool, + authRetryCount: int +) -> void: if result != HTTPRequest.RESULT_SUCCESS: var message := "网络请求失败: %s" % _http_result_to_string(result) request_failed.emit(endpoint, message) callback.call(false, {}, {"message": message}) return + if responseCode == 401 and authenticated and authRetryCount < 1: + _queue_authenticated_retry(endpoint, payload, callback, method, authRetryCount + 1) + return + var bodyText := body.get_string_from_utf8() var json := JSON.new() if json.parse(bodyText) != OK: @@ -100,6 +128,63 @@ func _handle_response(endpoint: String, result: int, responseCode: int, body: Pa request_failed.emit(endpoint, str(errorInfo.get("message", "请求失败"))) callback.call(false, response, errorInfo) +func _queue_authenticated_retry(endpoint: String, payload: Dictionary, callback: Callable, method: int, authRetryCount: int) -> void: + _pendingAuthRetries.append({ + "endpoint": endpoint, + "payload": payload.duplicate(true), + "callback": callback, + "method": method, + "auth_retry_count": authRetryCount, + "account_generation": _current_account_generation(), + }) + if _awaitingSessionRefresh: + return + + var authManager := get_node_or_null("/root/AuthManager") + if authManager == null or not authManager.has_method("refresh_session"): + _finish_authenticated_retries(false) + return + _awaitingSessionRefresh = true + if not bool(authManager.call("refresh_session")): + _finish_authenticated_retries(false) + +func _on_session_refresh_completed(success: bool) -> void: + _finish_authenticated_retries(success) + +func _finish_authenticated_retries(success: bool) -> void: + var retries := _pendingAuthRetries.duplicate() + _pendingAuthRetries.clear() + _awaitingSessionRefresh = false + var currentGeneration := _current_account_generation() + for retryVariant in retries: + if not (retryVariant is Dictionary): + continue + var retry: Dictionary = retryVariant + var callback: Callable = retry.get("callback", Callable()) + if not callback.is_valid(): + continue + if not success or int(retry.get("account_generation", -1)) != currentGeneration: + callback.call(false, {}, { + "message": "登录状态已过期,请重新登录", + "response_code": 401, + "error_code": "SESSION_EXPIRED", + }) + continue + _request_json( + str(retry.get("endpoint", "")), + retry.get("payload", {}) as Dictionary, + callback, + int(retry.get("method", HTTPClient.METHOD_GET)), + true, + int(retry.get("auth_retry_count", 1)) + ) + +func _current_account_generation() -> int: + var authManager := get_node_or_null("/root/AuthManager") + if authManager == null or not authManager.has_method("get_account_generation"): + return -1 + return int(authManager.call("get_account_generation")) + func _http_result_to_string(result: int) -> String: match result: HTTPRequest.RESULT_CHUNKED_BODY_SIZE_MISMATCH: diff --git a/_Core/managers/AppearanceManager.gd b/_Core/managers/AppearanceManager.gd index 565436d..ddea182 100644 --- a/_Core/managers/AppearanceManager.gd +++ b/_Core/managers/AppearanceManager.gd @@ -21,6 +21,7 @@ const REMOTE_SKIN_MAX_RETRIES: int = 3 const REMOTE_SKIN_CACHE_DIR: String = "user://skin_cache" const AVATAR_CORNER_RADIUS_RATIO: float = 0.22 const DEFAULT_CHARACTER_TEXTURE_PATH: String = "res://assets/characters/player_pixel_spritesheet.png" +const AVATAR_MASK_SHADER = preload("res://assets/shaders/avatar_round_mask.gdshader") const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd") const SKINS: Array[Dictionary] = [ @@ -99,9 +100,12 @@ var _remoteSkinRetryCounts: Dictionary = {} var _accountAvatarUrl: String = "" var _accountAvatarTexture: Texture2D var _profileSyncSuppressed: bool = false +var _confirmedSkinId: String = FALLBACK_SKIN_ID +var _pendingSkinId: String = "" func _ready() -> void: _load() + _confirmedSkinId = selectedSkinId _connect_auth_events() _emit_profile_changed() @@ -226,12 +230,17 @@ func set_selected_skin(skinId: String, allowLocked: bool = false) -> bool: if normalizedId == selectedSkinId: return true + var shouldSync := not _profileSyncSuppressed and _is_account_authenticated() + if shouldSync: + _pendingSkinId = normalizedId selectedSkinId = normalizedId _save() _emit_skin_changed() _emit_profile_changed() - if not _profileSyncSuppressed: + if shouldSync: _sync_account_skin_profile() + else: + _confirmedSkinId = normalizedId return true func apply_account_profile(profile: Dictionary) -> void: @@ -240,23 +249,28 @@ func apply_account_profile(profile: Dictionary) -> void: _clear_session_custom_skin() var skinId := str(profile.get("skin_id", "")).strip_edges() _accountAvatarUrl = _absolute_backend_url(str(profile.get("avatar_url", "")).strip_edges()) - _accountAvatarTexture = _texture_from_base64(str(profile.get("avatar_base64", "")).strip_edges()) + _accountAvatarTexture = _texture_from_base64(str(profile.get("avatar_base64", "")).strip_edges(), true) var ownedSkinsVariant: Variant = profile.get("owned_skins", []) if ownedSkinsVariant is Array: apply_account_skin_assets(ownedSkinsVariant as Array) var ownedVariant: Variant = profile.get("owned_skin_ids", []) if ownedVariant is Array: set_owned_skin_ids(ownedVariant) - if not skinId.is_empty(): + var shouldApplySkin := _pendingSkinId.is_empty() or skinId == _pendingSkinId + if not skinId.is_empty() and shouldApplySkin: grant_owned_skin(skinId) set_selected_skin(skinId, true) - else: + _confirmedSkinId = skinId + if skinId == _pendingSkinId: + _pendingSkinId = "" + elif skinId.is_empty() and _pendingSkinId.is_empty(): var fallbackSkinId := _first_owned_skin_id() if selectedSkinId != fallbackSkinId: selectedSkinId = fallbackSkinId _save() _emit_skin_changed() _emit_profile_changed() + _confirmedSkinId = fallbackSkinId _profileSyncSuppressed = false if _accountAvatarTexture != null or not _accountAvatarUrl.is_empty(): selectedAvatarId = CUSTOM_AVATAR_ID @@ -306,7 +320,7 @@ func apply_account_avatar_base64(avatarUrl: String, avatarBase64: String) -> voi _accountAvatarUrl = _absolute_backend_url(avatarUrl.strip_edges()) _customAvatarTexture = null _customAvatarActive = false - _accountAvatarTexture = _texture_from_base64(avatarBase64) + _accountAvatarTexture = _texture_from_base64(avatarBase64, true) if _accountAvatarTexture != null or not _accountAvatarUrl.is_empty(): selectedAvatarId = CUSTOM_AVATAR_ID else: @@ -715,11 +729,45 @@ func _sync_account_skin_profile() -> void: func _connect_auth_events() -> void: var eventSystem := get_node_or_null("/root/EventSystem") - if eventSystem == null: + if eventSystem != null: + eventSystem.call("connect_event", EventNames.AUTH_LOGOUT, _on_auth_logout, self) + var authManager := get_node_or_null("/root/AuthManager") + if authManager != null and authManager.has_signal("appearance_update_succeeded"): + authManager.connect("appearance_update_succeeded", _on_appearance_update_succeeded) + if authManager != null and authManager.has_signal("appearance_update_failed"): + authManager.connect("appearance_update_failed", _on_appearance_update_failed) + +func _on_appearance_update_succeeded(requestedSkinId: String, profile: Dictionary) -> void: + if requestedSkinId != _pendingSkinId: return - eventSystem.call("connect_event", EventNames.AUTH_LOGOUT, _on_auth_logout, self) + var confirmedSkinId := str(profile.get("skin_id", requestedSkinId)).strip_edges() + if confirmedSkinId.is_empty(): + confirmedSkinId = requestedSkinId + _pendingSkinId = "" + _confirmedSkinId = confirmedSkinId + if selectedSkinId != confirmedSkinId: + _apply_selected_skin_locally(confirmedSkinId) + +func _on_appearance_update_failed(requestedSkinId: String, _message: String) -> void: + if requestedSkinId != _pendingSkinId: + return + _pendingSkinId = "" + if not _confirmedSkinId.is_empty() and selectedSkinId == requestedSkinId: + _apply_selected_skin_locally(_confirmedSkinId) + +func _apply_selected_skin_locally(skinId: String) -> void: + selectedSkinId = skinId + _save() + _emit_skin_changed() + _emit_profile_changed() + +func _is_account_authenticated() -> bool: + var authManager := get_node_or_null("/root/AuthManager") + return authManager != null and authManager.has_method("is_authenticated") and bool(authManager.call("is_authenticated")) func _on_auth_logout(_data: Variant = null) -> void: + _pendingSkinId = "" + _confirmedSkinId = FALLBACK_SKIN_ID clear_account_profile() func get_profile_payload() -> Dictionary: @@ -762,11 +810,19 @@ func _get_or_create_avatar_texture_rect(panel: PanelContainer) -> TextureRect: textureRect.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR textureRect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE textureRect.stretch_mode = TextureRect.STRETCH_SCALE + textureRect.material = _create_avatar_mask_material() textureRect.custom_minimum_size = panel.custom_minimum_size textureRect.set_anchors_preset(Control.PRESET_FULL_RECT) panel.add_child(textureRect) return textureRect +func _create_avatar_mask_material() -> ShaderMaterial: + var material := ShaderMaterial.new() + material.shader = AVATAR_MASK_SHADER + material.set_shader_parameter("corner_radius", 0.22) + material.set_shader_parameter("edge_feather", 0.008) + return material + func _normalize_account_skin_asset(skin: Dictionary) -> Dictionary: var normalized := skin.duplicate(true) var skinId := str(normalized.get("id", normalized.get("skin_id", ""))).strip_edges() @@ -784,7 +840,7 @@ func _normalize_account_skin_asset(skin: Dictionary) -> Dictionary: normalized["account_asset"] = true return normalized -func _texture_from_base64(base64Text: String) -> Texture2D: +func _texture_from_base64(base64Text: String, roundAvatar: bool = false) -> Texture2D: var bytes := Marshalls.base64_to_raw(base64Text.strip_edges()) if bytes.is_empty(): return null @@ -792,6 +848,9 @@ func _texture_from_base64(base64Text: String) -> Texture2D: var err := image.load_png_from_buffer(bytes) if err != OK: return null + if roundAvatar: + image = _create_square_avatar_image(image) + _apply_rounded_rect_alpha(image) return ImageTexture.create_from_image(image) func _absolute_backend_url(path: String) -> String: diff --git a/_Core/managers/AuthManager.gd b/_Core/managers/AuthManager.gd index bfe3f8b..c71c4a9 100644 --- a/_Core/managers/AuthManager.gd +++ b/_Core/managers/AuthManager.gd @@ -16,6 +16,9 @@ signal email_verification_sent(email: String) signal email_verification_failed(message: String) signal profile_update_succeeded(profile: Dictionary) signal profile_update_failed(message: String) +signal appearance_update_succeeded(skin_id: String, profile: Dictionary) +signal appearance_update_failed(skin_id: String, message: String) +signal session_refresh_completed(success: bool) signal logout_completed() signal browser_bootstrap_received(kind: String) @@ -25,6 +28,8 @@ const DEFAULT_AUTH_CONFIG_PATH: String = "user://auth.cfg" const REQUEST_TIMEOUT: float = 12.0 const BROWSER_BOOTSTRAP_STORAGE_KEY: String = "whaletown.auth.bootstrap" const BROWSER_BOOTSTRAP_POLL_INTERVAL: float = 0.25 +const SESSION_REFRESH_CHECK_INTERVAL: float = 15.0 +const SESSION_REFRESH_LEAD_TIME: float = 120.0 var _access_token: String = "" var _refresh_token: String = "" @@ -41,19 +46,27 @@ var _registration_recovery_in_flight: bool = false var _registration_recovery_error: String = "" var _browser_bootstrap_kind: String = "" var _browser_bootstrap_poll_elapsed: float = 0.0 +var _session_refresh_check_elapsed: float = 0.0 +var _access_token_expires_at: float = 0.0 func _ready() -> void: _load_cached_session() _try_import_browser_bootstrap(false) + if is_authenticated() and _token_needs_refresh(): + call_deferred("refresh_session") func _process(delta: float) -> void: - if OS.get_name() != "Web" or not _browser_bootstrap_kind.is_empty(): - return - _browser_bootstrap_poll_elapsed += delta - if _browser_bootstrap_poll_elapsed < BROWSER_BOOTSTRAP_POLL_INTERVAL: - return - _browser_bootstrap_poll_elapsed = 0.0 - _try_import_browser_bootstrap(true) + _session_refresh_check_elapsed += delta + if _session_refresh_check_elapsed >= SESSION_REFRESH_CHECK_INTERVAL: + _session_refresh_check_elapsed = 0.0 + if is_authenticated() and _token_needs_refresh(): + refresh_session() + + if OS.get_name() == "Web" and _browser_bootstrap_kind.is_empty(): + _browser_bootstrap_poll_elapsed += delta + if _browser_bootstrap_poll_elapsed >= BROWSER_BOOTSTRAP_POLL_INTERVAL: + _browser_bootstrap_poll_elapsed = 0.0 + _try_import_browser_bootstrap(true) func _exit_tree() -> void: for request in _active_requests: @@ -112,8 +125,12 @@ func login(identifier: String, password: String) -> void: "password": password }, _on_login_response, HTTPClient.METHOD_POST, false, true) -func send_email_verification(email: String) -> void: +func send_email_verification(email: String, invitation_code: String = "") -> void: var normalized_email := email.strip_edges() + var normalized_invitation_code := invitation_code.strip_edges().to_upper() + if normalized_invitation_code.is_empty(): + email_verification_failed.emit("请输入邀请码") + return if normalized_email.is_empty(): email_verification_failed.emit("请输入邮箱") return @@ -122,7 +139,8 @@ func send_email_verification(email: String) -> void: return _request_json("/auth/send-email-verification", { - "email": normalized_email + "email": normalized_email, + "invitation_code": normalized_invitation_code }, func(success: bool, _data: Dictionary, error_info: Dictionary) -> void: if success: email_verification_sent.emit(normalized_email) @@ -130,11 +148,12 @@ func send_email_verification(email: String) -> void: email_verification_failed.emit(str(error_info.get("message", "验证码发送失败"))) ) -func register(username: String, password: String, nickname: String = "", email: String = "", email_verification_code: String = "", skin_id: String = "") -> void: +func register(username: String, password: String, nickname: String = "", email: String = "", email_verification_code: String = "", skin_id: String = "", invitation_code: String = "") -> void: var normalized_username := username.strip_edges() var normalized_nickname := nickname.strip_edges() var normalized_email := email.strip_edges() var normalized_code := email_verification_code.strip_edges() + var normalized_invitation_code := invitation_code.strip_edges().to_upper() if normalized_username.is_empty(): register_failed.emit("请输入用户名") @@ -142,6 +161,9 @@ func register(username: String, password: String, nickname: String = "", email: if not _is_valid_username(normalized_username): register_failed.emit("用户名只能包含字母、数字和下划线,长度 1-50") return + if normalized_invitation_code.is_empty(): + register_failed.emit("请输入邀请码") + return if password.length() < 8: register_failed.emit("密码至少 8 位,并需要包含字母和数字") return @@ -165,7 +187,8 @@ func register(username: String, password: String, nickname: String = "", email: "password": password, "nickname": normalized_nickname, "email": normalized_email, - "email_verification_code": normalized_code + "email_verification_code": normalized_code, + "invitation_code": normalized_invitation_code } var normalized_skin_id := skin_id.strip_edges() if not normalized_skin_id.is_empty(): @@ -197,10 +220,11 @@ func update_profile(profile_data: Dictionary) -> void: assetPayload.erase("skin_id") assetPayload.erase("settings") if profile_data.has("skin_id") and playerStateManager.has_method("update_appearance"): + var requestedSkinId := str(profile_data.get("skin_id", "")).strip_edges() playerStateManager.call( "update_appearance", - str(profile_data.get("skin_id", "")), - Callable(self, "_on_profile_response") + requestedSkinId, + Callable(self, "_on_appearance_profile_response").bind(requestedSkinId) ) if profile_data.has("settings") and playerStateManager.has_method("update_settings"): var settingsVariant: Variant = profile_data.get("settings", {}) @@ -227,14 +251,17 @@ func update_profile(profile_data: Dictionary) -> void: if not profile_data.has("skin_id") and not profile_data.has("settings"): profile_update_failed.emit("没有可保存的玩家资料") -func refresh_session() -> void: - if _refresh_token.strip_edges().is_empty() or _refresh_in_flight: - return +func refresh_session() -> bool: + if _refresh_token.strip_edges().is_empty(): + return false + if _refresh_in_flight: + return true _refresh_in_flight = true _request_json("/auth/refresh-token", { "refresh_token": _refresh_token }, _on_refresh_response, HTTPClient.METHOD_POST, false, true) + return true func logout() -> void: _clear_session_memory() @@ -388,10 +415,12 @@ func _clear_pending_registration() -> void: func _on_refresh_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void: _refresh_in_flight = false if not success: + session_refresh_completed.emit(false) logout() return _apply_auth_payload(data) + session_refresh_completed.emit(true) auth_state_changed.emit(true, get_current_user()) _refresh_player_snapshot() @@ -403,6 +432,11 @@ func _apply_auth_payload(payload: Dictionary) -> void: var auth_data: Dictionary = data_variant _access_token = str(auth_data.get("access_token", "")).strip_edges() _refresh_token = str(auth_data.get("refresh_token", _refresh_token)).strip_edges() + _access_token_expires_at = _jwt_expiration_time(_access_token) + if _access_token_expires_at <= 0.0: + var expiresIn := float(auth_data.get("expires_in", 0.0)) + if expiresIn > 0.0: + _access_token_expires_at = Time.get_unix_time_from_system() + expiresIn var user_variant: Variant = auth_data.get("user", {}) if user_variant is Dictionary: @@ -466,6 +500,36 @@ func _on_profile_response(success: bool, data: Dictionary, _error_info: Dictiona profile_update_succeeded.emit(get_current_profile()) auth_state_changed.emit(is_authenticated(), get_current_user()) +func _on_appearance_profile_response( + success: bool, + data: Dictionary, + errorInfo: Dictionary, + requestedSkinId: String +) -> void: + if not success: + var message := str(errorInfo.get("message", "玩家资料保存失败")) + appearance_update_failed.emit(requestedSkinId, message) + profile_update_failed.emit(message) + auth_state_changed.emit(is_authenticated(), get_current_user()) + return + + var dataVariant: Variant = data.get("data", {}) + if not (dataVariant is Dictionary): + var message := "玩家状态响应格式错误" + appearance_update_failed.emit(requestedSkinId, message) + profile_update_failed.emit(message) + auth_state_changed.emit(is_authenticated(), get_current_user()) + return + var snapshot := dataVariant as Dictionary + var playerStateManager := get_node_or_null("/root/PlayerStateManager") + if playerStateManager != null and playerStateManager.has_method("apply_snapshot"): + playerStateManager.call("apply_snapshot", snapshot) + _apply_snapshot_profile_payload(snapshot) + var profile := get_current_profile() + appearance_update_succeeded.emit(requestedSkinId, profile) + profile_update_succeeded.emit(profile) + auth_state_changed.emit(is_authenticated(), get_current_user()) + func _on_profile_assets_response(success: bool, data: Dictionary, _error_info: Dictionary, requestGeneration: int) -> void: if requestGeneration != _account_generation: return @@ -539,6 +603,7 @@ func _load_cached_session(emit_cached_state: bool = true) -> void: _refresh_token = str(config.get_value("auth", "refresh_token", "")).strip_edges() _access_token = str(config.get_value("auth", "access_token", "")).strip_edges() + _access_token_expires_at = _jwt_expiration_time(_access_token) _current_user.clear() _current_profile.clear() @@ -554,6 +619,7 @@ func _emit_cached_auth_state() -> void: func _clear_session_memory() -> void: _access_token = "" _refresh_token = "" + _access_token_expires_at = 0.0 _current_user.clear() _current_profile.clear() _advance_account_generation() @@ -610,6 +676,26 @@ func _password_has_letter_and_number(password: String) -> bool: has_number = true return has_letter and has_number +func _token_needs_refresh() -> bool: + if _refresh_in_flight or _refresh_token.is_empty(): + return false + if _access_token_expires_at <= 0.0: + return false + return Time.get_unix_time_from_system() + SESSION_REFRESH_LEAD_TIME >= _access_token_expires_at + +func _jwt_expiration_time(token: String) -> float: + var parts := token.split(".") + if parts.size() != 3: + return 0.0 + var payload := str(parts[1]).replace("-", "+").replace("_", "/") + while payload.length() % 4 != 0: + payload += "=" + var jsonText := Marshalls.base64_to_raw(payload).get_string_from_utf8() + var json := JSON.new() + if json.parse(jsonText) != OK or not (json.data is Dictionary): + return 0.0 + return float((json.data as Dictionary).get("exp", 0.0)) + func _http_result_to_string(result: int) -> String: match result: HTTPRequest.RESULT_SUCCESS: diff --git a/_Core/managers/ChatManager.gd b/_Core/managers/ChatManager.gd index 18e0892..a1f6229 100644 --- a/_Core/managers/ChatManager.gd +++ b/_Core/managers/ChatManager.gd @@ -66,11 +66,13 @@ const CHAT_WEBSOCKET_MANAGER_SCRIPT: Script = preload("res://_Core/managers/WebS const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd") # WebSocket 服务器 URL(原生 WebSocket) -const WEBSOCKET_URL: String = "wss://whaletownend.xinghangee.icu/game" +const WEBSOCKET_URL: String = "wss://whaletown.novamailio.com/game" # 重连配置 const RECONNECT_MAX_ATTEMPTS: int = 5 const RECONNECT_BASE_DELAY: float = 3.0 +const APPEARANCE_BROADCAST_RETRY_DELAY: float = 1.5 +const APPEARANCE_BROADCAST_MAX_RETRIES: int = 5 # 频率限制配置 const RATE_LIMIT_MESSAGES: int = 10 @@ -98,7 +100,9 @@ const CHAT_ERROR_MESSAGES: Dictionary = { "PERMISSION_DENIED": "您没有权限发送消息", "SESSION_EXPIRED": "会话已过期,请重新连接", "ZULIP_ERROR": "消息服务暂时不可用", - "INTERNAL_ERROR": "服务器内部错误" + "INTERNAL_ERROR": "服务器内部错误", + "INSUFFICIENT_BALANCE": "鲸币余额不足,发布世界公告需要 100 鲸币", + "WALLET_UNAVAILABLE": "鲸币服务暂不可用,请稍后再试" } # ============================================================================ @@ -125,11 +129,18 @@ var _message_timestamps: Array[float] = [] # 当前用户信息 var _current_username: String = "" +var _current_user_id: String = "" var _current_map: String = "" var _world_ready_map: String = "" +var _world_ready_confirmed: bool = false +var _appearance_broadcast_pending: bool = false +var _appearance_broadcast_skin_id: String = "" +var _appearance_broadcast_retry_count: int = 0 +var _appearance_broadcast_retry_timer: Timer # 游戏 token var _game_token: String = "" +var _guest_mode: bool = false # 连接/登录完成前暂存用户已经提交的消息,避免刚进游戏时第一条消息被丢掉。 var _queued_messages: Array[Dictionary] = [] @@ -164,6 +175,10 @@ func _ready() -> void: _history_request.timeout = 12.0 _history_request.request_completed.connect(_on_history_request_completed) add_child(_history_request) + _appearance_broadcast_retry_timer = Timer.new() + _appearance_broadcast_retry_timer.one_shot = true + _appearance_broadcast_retry_timer.timeout.connect(_on_appearance_broadcast_retry_timeout) + add_child(_appearance_broadcast_retry_timer) # 连接信号 _connect_signals() @@ -209,13 +224,24 @@ func set_game_token(token: String) -> void: _queued_messages.clear() _recoverable_messages.clear() _world_ready_map = "" + _world_ready_confirmed = false + _clear_pending_appearance_broadcast() func is_logged_in() -> bool: return _is_logged_in -func mark_world_ready(mapId: String, position: Vector2) -> void: +func is_guest_mode() -> bool: + return _guest_mode + +func start_guest_session() -> void: + _guest_mode = true + _game_token = "" + _is_logged_in = false + connect_to_chat_server() + +func mark_world_ready(mapId: String, position: Vector2, direction: String = "down", movementState: String = "idle", sequence: int = 0) -> void: var normalizedMapId := mapId.strip_edges() - if not _is_logged_in or normalizedMapId.is_empty() or _world_ready_map == normalizedMapId: + if not _is_logged_in or normalizedMapId.is_empty() or (_world_ready_map == normalizedMapId and _world_ready_confirmed): return var payload := { @@ -223,9 +249,13 @@ func mark_world_ready(mapId: String, position: Vector2) -> void: "mapId": normalizedMapId, "x": position.x, "y": position.y, + "direction": direction, + "movementState": movementState, + "sequence": sequence, } if _websocket_manager.send_message(JSON.stringify(payload)) == OK: _world_ready_map = normalizedMapId + _world_ready_confirmed = false func leave_world(sceneId: String = "private_space") -> void: if not _is_logged_in or _world_ready_map.is_empty(): @@ -239,11 +269,60 @@ func leave_world(sceneId: String = "private_space") -> void: } if _websocket_manager.send_message(JSON.stringify(payload)) == OK: _world_ready_map = "" + _world_ready_confirmed = false + _stop_appearance_broadcast_retry() -func notify_appearance_changed() -> void: - if not _is_logged_in or _world_ready_map.is_empty(): +func notify_appearance_changed(skinId: String = "") -> void: + var normalizedSkinId := skinId.strip_edges() + if not normalizedSkinId.is_empty(): + _appearance_broadcast_skin_id = normalizedSkinId + _appearance_broadcast_pending = true + _appearance_broadcast_retry_count = 0 + _try_send_pending_appearance_broadcast() + +func _try_send_pending_appearance_broadcast() -> void: + if not _appearance_broadcast_pending or not _is_logged_in or not _world_ready_confirmed: return + if _world_ready_map.is_empty() or _appearance_broadcast_retry_count >= APPEARANCE_BROADCAST_MAX_RETRIES: + return + _appearance_broadcast_retry_count += 1 _websocket_manager.send_message(JSON.stringify({"type": "appearance_changed"})) + _schedule_appearance_broadcast_retry() + +func _schedule_appearance_broadcast_retry() -> void: + if _appearance_broadcast_retry_count >= APPEARANCE_BROADCAST_MAX_RETRIES: + return + if is_instance_valid(_appearance_broadcast_retry_timer): + _appearance_broadcast_retry_timer.start(APPEARANCE_BROADCAST_RETRY_DELAY) + +func _stop_appearance_broadcast_retry() -> void: + if is_instance_valid(_appearance_broadcast_retry_timer): + _appearance_broadcast_retry_timer.stop() + +func _clear_pending_appearance_broadcast() -> void: + _appearance_broadcast_pending = false + _appearance_broadcast_skin_id = "" + _appearance_broadcast_retry_count = 0 + _stop_appearance_broadcast_retry() + +func _on_appearance_broadcast_retry_timeout() -> void: + _try_send_pending_appearance_broadcast() + +func _handle_world_ready_success(data: Dictionary) -> void: + var confirmedMapId := str(data.get("mapId", data.get("map_id", ""))).strip_edges() + if not confirmedMapId.is_empty(): + _world_ready_map = confirmedMapId + _world_ready_confirmed = not _world_ready_map.is_empty() + if _appearance_broadcast_pending: + _appearance_broadcast_retry_count = 0 + _try_send_pending_appearance_broadcast() + +func _handle_appearance_changed_success(data: Dictionary) -> void: + var confirmedSkinId := str(data.get("skinId", data.get("skin_id", ""))).strip_edges() + if not _appearance_broadcast_skin_id.is_empty() and confirmedSkinId != _appearance_broadcast_skin_id: + _schedule_appearance_broadcast_retry() + return + _clear_pending_appearance_broadcast() # 获取游戏 token # @@ -267,12 +346,12 @@ func connect_to_chat_server() -> void: push_warning("聊天服务器正在连接") return - if _game_token.strip_edges().is_empty(): + if not _guest_mode and _game_token.strip_edges().is_empty(): var authToken := _get_auth_manager_token() if not authToken.is_empty(): _game_token = authToken - if _game_token.strip_edges().is_empty(): + if not _guest_mode and _game_token.strip_edges().is_empty(): _handle_error("AUTH_FAILED", "请先登录后再使用聊天") return @@ -291,6 +370,7 @@ func disconnect_from_chat_server() -> void: # 断开连接 _websocket_manager.disconnect_websocket() + _guest_mode = false # 检查是否已连接 # @@ -325,6 +405,49 @@ func can_attempt_chat_connection() -> bool: func send_chat_message(content: String, scope: String = "local", show_bubble: bool = false) -> bool: return _send_chat_payload(content, scope, {"bubble": show_bubble and _settings_bool("show_chat_bubbles", true)}) +## Publish a paid world announcement. The server owns validation and charging. +func send_world_bulletin(content: String) -> bool: + if not _is_logged_in: + _handle_error("AUTH_FAILED", "请先登录后再发布世界公告") + return false + # 客户端只表达发布意图;价格、实时余额校验和扣费全由服务端处理。 + return _send_chat_payload(content, "global", { + "bubble": false, + "worldBulletin": true, + "_suppressLocalEcho": true, + "_nonRecoverable": true + }) + +func interact_with_world_npc(npc_id: String, message: String = "", session_id: String = "") -> bool: + var normalized_npc_id := npc_id.strip_edges() + if normalized_npc_id.is_empty() or not _is_logged_in: + return false + if not _websocket_manager.is_websocket_connected(): + connect_to_chat_server() + return false + var payload := { + "type": "npc_interact", + "npcId": normalized_npc_id, + "message": message.strip_edges().left(300), + } + if not session_id.strip_edges().is_empty(): + payload["sessionId"] = session_id.strip_edges() + var send_err: Error = _websocket_manager.send_message(JSON.stringify(payload)) + if send_err != OK: + _handle_error("SEND_FAILED", "WebSocket send failed: %s" % error_string(send_err)) + return false + return true + +func end_world_npc_session(npc_id: String, session_id: String = "") -> bool: + if npc_id.strip_edges().is_empty() or not _is_logged_in or not _websocket_manager.is_websocket_connected(): + return false + return _websocket_manager.send_message(JSON.stringify({ + "type": "npc_session_end", "npcId": npc_id.strip_edges(), "sessionId": session_id.strip_edges(), + })) == OK + +func get_current_user_id() -> String: + return _current_user_id + func send_private_message(content: String, target_user_id: String, target_username: String = "", private_context: String = "") -> bool: if not _settings_bool("allow_nearby_private", true) and private_context.strip_edges() == "whisper": _handle_error("PRIVATE_DISABLED", "附近私聊已在设置中关闭") @@ -449,6 +572,7 @@ func _send_chat_payload(content: String, scope: String = "local", extra_data: Di var show_bubble: bool = bool(extra_data.get("bubble", extra_data.get("showBubble", extra_data.get("show_bubble", false)))) and _settings_bool("show_chat_bubbles", true) var suppress_local_echo := bool(extra_data.get("_suppressLocalEcho", false)) var is_recovery_retry := bool(extra_data.get("_isRecoveryRetry", false)) + var non_recoverable := bool(extra_data.get("_nonRecoverable", false)) var recoverable_extra_data := extra_data.duplicate(true) recoverable_extra_data.erase("_suppressLocalEcho") @@ -501,11 +625,12 @@ func _send_chat_payload(content: String, scope: String = "local", extra_data: Di if send_err != OK: _handle_error("SEND_FAILED", "WebSocket send failed: %s" % error_string(send_err)) return false - _track_recoverable_message({ - "content": normalized_content, - "scope": scope, - "extra_data": recoverable_extra_data - }) + if not non_recoverable: + _track_recoverable_message({ + "content": normalized_content, + "scope": scope, + "extra_data": recoverable_extra_data + }) # 记录发送时间 if not is_recovery_retry: @@ -575,10 +700,12 @@ func _on_chat_message_sent(_request_id: String, success: bool, data: Dictionary, # # 使用示例: # ChatManager.update_player_position(150.0, 200.0, "novice_village") -func update_player_position(x: float, y: float, map_id: String) -> void: - update_player_position_with_appearance(x, y, map_id, {}) +func update_player_position(x: float, y: float, map_id: String, direction: String = "down", movementState: String = "walk", sequence: int = 0) -> void: + update_player_position_with_appearance(x, y, map_id, {}, direction, movementState, sequence) -func update_player_position_with_appearance(x: float, y: float, map_id: String, _appearance: Dictionary = {}) -> void: +func update_player_position_with_appearance(x: float, y: float, map_id: String, _appearance: Dictionary = {}, direction: String = "down", movementState: String = "walk", sequence: int = 0) -> void: + if _guest_mode: + return if not _websocket_manager.is_websocket_connected(): connect_to_chat_server() return @@ -589,7 +716,10 @@ func update_player_position_with_appearance(x: float, y: float, map_id: String, "type": "position", "x": x, "y": y, - "mapId": map_id + "mapId": map_id, + "direction": direction, + "movementState": movementState, + "sequence": sequence } # 发送消息(JSON 字符串) var json_string := JSON.stringify(position_data) @@ -604,6 +734,8 @@ func update_player_position_with_appearance(x: float, y: float, map_id: String, # 返回值: # bool - 是否可以发送 func can_send_message() -> bool: + if _guest_mode: + return false var current_time := Time.get_unix_time_from_system() # 清理过期的时间戳 @@ -794,6 +926,8 @@ func _connect_signals() -> void: func _on_connection_lost() -> void: _is_logged_in = false _world_ready_map = "" + _world_ready_confirmed = false + _stop_appearance_broadcast_retry() _restore_recoverable_messages_to_queue() func _load_token_from_environment() -> void: @@ -911,6 +1045,9 @@ func _preview_text(text: String) -> String: # 发送登录消息 func _send_login_message() -> void: + if _guest_mode: + _websocket_manager.send_message(JSON.stringify({"type": "guest_login"})) + return if _game_token.strip_edges().is_empty(): _handle_error("AUTH_FAILED", "缺少聊天认证 token") return @@ -967,8 +1104,12 @@ func _on_data_received(message: String) -> void: match message_type: "connected": pass + "pong": + pass "login_success": _handle_login_success(data) + "guest_login_success": + _handle_guest_login_success(data) "login_error": _handle_login_error(data) "chat": @@ -997,6 +1138,50 @@ func _on_data_received(message: String) -> void: _handle_friend_error(data) "map_players_snapshot": _handle_map_players_snapshot(data) + "npc_snapshot": + _handle_npc_snapshot(data) + "npc_action_started": + _emit_event(EventNames.NPC_ACTION_STARTED, _normalize_npc_action_event(data)) + "npc_action_completed": + _emit_event(EventNames.NPC_ACTION_COMPLETED, _normalize_npc_action_event(data)) + "npc_spoke": + _emit_event(EventNames.NPC_SPOKE, { + "npc_id": str(data.get("npcId", data.get("npc_id", ""))), + "npc_name": str(data.get("npcName", data.get("npc_name", "NPC"))), + "response": str(data.get("response", "")), + "public_intention": str(data.get("publicIntention", data.get("public_intention", ""))), + "activity": data.get("activity", {}), + "memory_id": str(data.get("memoryId", data.get("memory_id", ""))), + "target_user_id": str(data.get("targetUserId", data.get("target_user_id", ""))), + "target_username": str(data.get("targetUsername", data.get("target_username", ""))), + "session_id": str(data.get("sessionId", data.get("session_id", ""))), + }) + "npc_interaction_success": + pass + "npc_interaction_error": + _emit_event(EventNames.NPC_INTERACTION_ERROR, { + "npc_id": str(data.get("npcId", data.get("npc_id", ""))), + "code": str(data.get("code", "INTERACTION_REJECTED")), + "message": str(data.get("message", "NPC暂时无法回应")), + }) + "npc_conversation": + var conversationLines: Array[Dictionary] = [] + var rawLines: Variant = data.get("lines", []) + if rawLines is Array: + for rawLine in rawLines: + if rawLine is Dictionary: + conversationLines.append({ + "speaker_npc_id": str(rawLine.get("speakerNpcId", rawLine.get("speaker_npc_id", ""))), + "speaker_name": str(rawLine.get("speakerName", rawLine.get("speaker_name", "NPC"))), + "text": str(rawLine.get("text", "")), + }) + _emit_event(EventNames.NPC_CONVERSATION, { + "conversation_id": str(data.get("conversationId", data.get("conversation_id", ""))), + "encounter_id": str(data.get("encounterId", data.get("encounter_id", ""))), + "mapId": str(data.get("mapId", data.get("map_id", ""))), + "location_id": str(data.get("locationId", data.get("location_id", ""))), + "lines": conversationLines, + }) "system_presence": _handle_system_presence(data) "position_update": @@ -1006,9 +1191,13 @@ func _on_data_received(message: String) -> void: "appearance_changed": _handle_player_joined(data) "world_ready_success": - pass + _handle_world_ready_success(data) + "appearance_changed_success": + _handle_appearance_changed_success(data) "world_left": _world_ready_map = "" + _world_ready_confirmed = false + _stop_appearance_broadcast_retry() "player_left": _handle_player_left(data) "position_updated": @@ -1031,13 +1220,17 @@ func _warn_empty_message_type_limited(data: Dictionary) -> void: # 处理登录成功 func _handle_login_success(data: Dictionary) -> void: + _guest_mode = false _is_logged_in = true _refreshing_auth_session = false _chat_login_refresh_attempted = false _friend_protocol_supported = true _current_username = data.get("username", "") + _current_user_id = str(data.get("userId", data.get("user_id", ""))) _current_map = data.get("currentMap", "") _world_ready_map = "" + _world_ready_confirmed = false + _stop_appearance_broadcast_retry() # 重置当前会话缓存(每次登录/重连都清空,重新开始接收消息) reset_session() @@ -1051,6 +1244,18 @@ func _handle_login_success(data: Dictionary) -> void: _flush_queued_messages() request_friend_list() +func _handle_guest_login_success(data: Dictionary) -> void: + _guest_mode = true + _is_logged_in = true + _friend_protocol_supported = false + _current_username = "游客" + _current_user_id = "" + _current_map = str(data.get("currentMap", "whale_port")) + _world_ready_map = "" + _world_ready_confirmed = false + reset_session() + _emit_event(EventNames.CHAT_LOGIN_SUCCESS, {"username": "游客", "current_map": _current_map, "guest": true}) + func _handle_system_presence(data: Dictionary) -> void: var content := str(data.get("content", "")).strip_edges() if content.is_empty(): @@ -1070,8 +1275,8 @@ func _handle_system_presence(data: Dictionary) -> void: chat_message_received.emit("系统", content, false, timestamp) _emit_event(EventNames.CHAT_MESSAGE_RECEIVED, message) -func _on_account_profile_updated(_profile: Dictionary) -> void: - notify_appearance_changed() +func _on_account_profile_updated(profile: Dictionary) -> void: + notify_appearance_changed(str(profile.get("skin_id", ""))) func _on_auth_state_changed(isAuthenticated: bool, _user: Dictionary) -> void: if not isAuthenticated: @@ -1111,6 +1316,19 @@ func _handle_login_error(data: Dictionary) -> void: func _handle_chat_sent(data: Dictionary) -> void: var message_id: String = str(data.get("messageId", "")) var timestamp: float = data.get("timestamp", 0.0) + var is_world_bulletin: bool = bool(data.get("worldBulletin", data.get("world_bulletin", false))) + if is_world_bulletin and data.has("balance"): + # 直接采用这次后端交易返回的余额,不在客户端自行计算扣费。 + var player_state_manager := get_node_or_null("/root/PlayerStateManager") + if player_state_manager != null and player_state_manager.has_method("apply_wallet"): + var wallet: Dictionary = {} + if player_state_manager.has_method("get_wallet"): + var current_wallet: Variant = player_state_manager.call("get_wallet") + if current_wallet is Dictionary: + wallet = (current_wallet as Dictionary).duplicate(true) + wallet["balance"] = int(data.get("balance", 0)) + wallet["currency"] = "whale_coin" + player_state_manager.call("apply_wallet", wallet) # 发射信号 chat_message_sent.emit(message_id, timestamp) @@ -1118,24 +1336,28 @@ func _handle_chat_sent(data: Dictionary) -> void: # 通过 EventSystem 广播(Signal Up) _emit_event(EventNames.CHAT_MESSAGE_SENT, { "message_id": message_id, - "timestamp": timestamp + "timestamp": timestamp, + "world_bulletin": is_world_bulletin, + "charged": int(data.get("charged", 0)), + "balance": int(data.get("balance", 0)) }) - if not _recoverable_messages.is_empty(): + if not is_world_bulletin and not _recoverable_messages.is_empty(): _recoverable_messages.pop_front() # 处理聊天消息发送失败 func _handle_chat_error(data: Dictionary) -> void: var error_message: String = data.get("message", "消息发送失败") var error_code: String = str(data.get("code", data.get("error_code", "CHAT_SEND_FAILED"))).strip_edges() - if error_code == "SESSION_EXPIRED": - _handle_session_expired(error_message) - return + var is_world_bulletin: bool = bool(data.get("worldBulletin", data.get("world_bulletin", false))) # 通过 EventSystem 广播错误(Signal Up) _emit_event(EventNames.CHAT_ERROR_OCCURRED, { "error_code": error_code if not error_code.is_empty() else "CHAT_SEND_FAILED", - "message": error_message + "message": error_message, + "world_bulletin": is_world_bulletin }) + if error_code == "SESSION_EXPIRED": + _handle_session_expired(error_message) func _handle_friend_list(data: Dictionary) -> void: _friends.clear() @@ -1256,6 +1478,7 @@ func _handle_chat_render(data: Dictionary) -> void: var to_username: String = str(data.get("toUsername", data.get("to_username", data.get("targetUsername", data.get("target_username", ""))))).strip_edges() var private_context: String = str(data.get("privateContext", data.get("private_context", ""))).strip_edges() var is_private: bool = scope == "private" + var is_world_bulletin: bool = bool(data.get("worldBulletin", data.get("world_bulletin", false))) var timestamp: float = _parse_chat_timestamp_to_unix(data.get("timestamp", 0.0)) @@ -1280,7 +1503,8 @@ func _handle_chat_render(data: Dictionary) -> void: "to_user_id": to_user_id, "to_username": to_username, "private_context": private_context, - "is_private": is_private + "is_private": is_private, + "world_bulletin": is_world_bulletin }) # 发射信号 @@ -1298,7 +1522,8 @@ func _handle_chat_render(data: Dictionary) -> void: "to_user_id": to_user_id, "to_username": to_username, "private_context": private_context, - "is_private": is_private + "is_private": is_private, + "world_bulletin": is_world_bulletin }) # 解析聊天消息时间戳(兼容 unix 秒 / ISO 8601 字符串) @@ -1359,13 +1584,16 @@ func _handle_position_update(data: Dictionary) -> void: "userId": user_id, "username": str(data.get("username", "")), "position": Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))), - "mapId": str(data.get("mapId", data.get("map_id", ""))), - "skin_id": str(appearance.get("skin_id", "")), - "avatar_id": str(appearance.get("avatar_id", "")), - "skin_asset": appearance.get("skin_asset", {}), - "cafe_companion": _normalize_cafe_companion(data.get("cafeCompanion", data.get("cafe_companion", null))), - "movement_locked": bool(data.get("movementLocked", data.get("movement_locked", false))) - }) + "direction": str(data.get("direction", "down")), + "movement_state": str(data.get("movementState", data.get("movement_state", "walk"))), + "sequence": int(data.get("sequence", -1)), + "mapId": str(data.get("mapId", data.get("map_id", ""))), + "skin_id": str(appearance.get("skin_id", "")), + "avatar_id": str(appearance.get("avatar_id", "")), + "skin_asset": appearance.get("skin_asset", {}), + "cafe_companion": _normalize_cafe_companion(data.get("cafeCompanion", data.get("cafe_companion", null))), + "movement_locked": bool(data.get("movementLocked", data.get("movement_locked", false))) + }) func _handle_map_players_snapshot(data: Dictionary) -> void: var players: Array[Dictionary] = [] @@ -1383,19 +1611,88 @@ func _handle_map_players_snapshot(data: Dictionary) -> void: "userId": user_id, "username": str(player.get("username", "")), "position": Vector2(float(player.get("x", 0.0)), float(player.get("y", 0.0))), - "mapId": str(player.get("mapId", player.get("map_id", data.get("mapId", data.get("map_id", ""))))), - "skin_id": str(appearance.get("skin_id", "")), - "avatar_id": str(appearance.get("avatar_id", "")), - "skin_asset": appearance.get("skin_asset", {}), - "cafe_companion": _normalize_cafe_companion(player.get("cafeCompanion", player.get("cafe_companion", null))), - "movement_locked": bool(player.get("movementLocked", player.get("movement_locked", false))) - }) + "direction": str(player.get("direction", "down")), + "movement_state": str(player.get("movementState", player.get("movement_state", "idle"))), + "sequence": int(player.get("sequence", -1)), + "mapId": str(player.get("mapId", player.get("map_id", data.get("mapId", data.get("map_id", ""))))), + "skin_id": str(appearance.get("skin_id", "")), + "avatar_id": str(appearance.get("avatar_id", "")), + "skin_asset": appearance.get("skin_asset", {}), + "cafe_companion": _normalize_cafe_companion(player.get("cafeCompanion", player.get("cafe_companion", null))), + "movement_locked": bool(player.get("movementLocked", player.get("movement_locked", false))) + }) _emit_event(EventNames.REMOTE_PLAYERS_SNAPSHOT_READY, { "mapId": str(data.get("mapId", data.get("map_id", ""))), "players": players }) +func _handle_npc_snapshot(data: Dictionary) -> void: + var npcs: Array[Dictionary] = [] + var npcsVariant: Variant = data.get("npcs", []) + if npcsVariant is Array: + for npcVariant in npcsVariant: + if not (npcVariant is Dictionary): + continue + var npc: Dictionary = npcVariant + var npcId := str(npc.get("npcId", npc.get("npc_id", ""))).strip_edges() + if npcId.is_empty(): + continue + npcs.append({ + "npc_id": npcId, + "name": str(npc.get("name", "NPC")), + "x": float(npc.get("x", 0.0)), + "y": float(npc.get("y", 0.0)), + "direction": str(npc.get("direction", "down")), + "movement_state": str(npc.get("movementState", npc.get("movement_state", "idle"))), + "state": str(npc.get("state", "idle")), + "version": int(npc.get("version", 0)), + "daily_goal": str(npc.get("dailyGoal", npc.get("daily_goal", ""))), + "plan_source": str(npc.get("planSource", npc.get("plan_source", "fallback"))), + "current_activity": npc.get("currentActivity", npc.get("current_activity", {})), + "public_intention": str(npc.get("publicIntention", npc.get("public_intention", ""))), + "dialogue": str(npc.get("dialogue", "")), + "scene": str(npc.get("scene", "classic_whale")), + "active_action": _normalize_npc_action(npc.get("activeAction", npc.get("active_action", null))), + }) + + _emit_event(EventNames.NPC_SNAPSHOT_READY, { + "mapId": str(data.get("mapId", data.get("map_id", ""))), + "server_now": int(data.get("serverNow", data.get("server_now", 0))), + "version": int(data.get("version", 0)), + "npcs": npcs, + }) + +func _normalize_npc_action_event(data: Dictionary) -> Dictionary: + return { + "mapId": str(data.get("mapId", data.get("map_id", ""))), + "server_now": int(data.get("serverNow", data.get("server_now", 0))), + "npc_id": str(data.get("npcId", data.get("npc_id", ""))), + "action": _normalize_npc_action(data.get("action", {})), + } + +func _normalize_npc_action(value: Variant) -> Dictionary: + if not (value is Dictionary): + return {} + var action: Dictionary = value + return { + "action_id": str(action.get("actionId", action.get("action_id", ""))), + "kind": str(action.get("kind", "walk")), + "from_x": float(action.get("fromX", action.get("from_x", 0.0))), + "from_y": float(action.get("fromY", action.get("from_y", 0.0))), + "to_x": float(action.get("toX", action.get("to_x", 0.0))), + "to_y": float(action.get("toY", action.get("to_y", 0.0))), + "from_map_id": str(action.get("fromMapId", action.get("from_map_id", ""))), + "to_map_id": str(action.get("toMapId", action.get("to_map_id", ""))), + "from_location_id": str(action.get("fromLocationId", action.get("from_location_id", ""))), + "to_location_id": str(action.get("toLocationId", action.get("to_location_id", ""))), + "activity_id": str(action.get("activityId", action.get("activity_id", ""))), + "activity_kind": str(action.get("activityKind", action.get("activity_kind", ""))), + "started_at": int(action.get("startedAt", action.get("started_at", 0))), + "completes_at": int(action.get("completesAt", action.get("completes_at", 0))), + "version": int(action.get("version", 0)), + } + func _handle_player_joined(data: Dictionary) -> void: var user_id := str(data.get("userId", data.get("user_id", ""))).strip_edges() if user_id.is_empty(): @@ -1406,19 +1703,25 @@ func _handle_player_joined(data: Dictionary) -> void: "userId": user_id, "username": str(data.get("username", "")), "position": Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))), - "mapId": str(data.get("mapId", data.get("map_id", ""))), - "skin_id": str(appearance.get("skin_id", "")), - "avatar_id": str(appearance.get("avatar_id", "")), - "skin_asset": appearance.get("skin_asset", {}), - "cafe_companion": _normalize_cafe_companion(data.get("cafeCompanion", data.get("cafe_companion", null))), - "movement_locked": bool(data.get("movementLocked", data.get("movement_locked", false))) - }) + "direction": str(data.get("direction", "down")), + "movement_state": str(data.get("movementState", data.get("movement_state", "idle"))), + "sequence": int(data.get("sequence", -1)), + "mapId": str(data.get("mapId", data.get("map_id", ""))), + "skin_id": str(appearance.get("skin_id", "")), + "avatar_id": str(appearance.get("avatar_id", "")), + "skin_asset": appearance.get("skin_asset", {}), + "cafe_companion": _normalize_cafe_companion(data.get("cafeCompanion", data.get("cafe_companion", null))), + "movement_locked": bool(data.get("movementLocked", data.get("movement_locked", false))) + }) func _normalize_player_appearance(data: Dictionary) -> Dictionary: var appearanceVariant: Variant = data.get("appearance", {}) var appearance: Dictionary = appearanceVariant if appearanceVariant is Dictionary else {} + var skinId := str(data.get("skinId", data.get("skin_id", appearance.get("skinId", appearance.get("skin_id", ""))))).strip_edges() + if skinId.is_empty() or skinId == "pending_initial_skin": + skinId = "classic_whale" return { - "skin_id": str(data.get("skinId", data.get("skin_id", appearance.get("skinId", appearance.get("skin_id", ""))))), + "skin_id": skinId, "avatar_id": str(data.get("avatarId", data.get("avatar_id", appearance.get("avatarId", appearance.get("avatar_id", ""))))), "skin_asset": data.get("skinAsset", data.get("skin_asset", appearance.get("skinAsset", appearance.get("skin_asset", {})))), } diff --git a/_Core/managers/InputFocusManager.gd b/_Core/managers/InputFocusManager.gd new file mode 100644 index 0000000..d8f1a3c --- /dev/null +++ b/_Core/managers/InputFocusManager.gd @@ -0,0 +1,201 @@ +extends Node + +const FOCUS_BORDER_COLOR := Color(0.12, 0.49, 0.82, 1.0) +const CARET_COLOR := Color(0.04, 0.31, 0.60, 1.0) +const SELECTION_COLOR := Color(0.25, 0.61, 0.91, 0.30) +const STYLED_META := &"whaletown_input_focus_styled" +const INITIAL_PLACEHOLDER_META := &"whaletown_initial_placeholder" +const FOCUS_PLACEHOLDER_META := &"whaletown_focus_placeholder" +const NATIVE_INPUT_ID := "whaletown-native-input" + +var _javascript_callback: Variant +var _native_inputs: Dictionary = {} +var _active_native_input: Control +var _bridge_initialized := false + +func _ready() -> void: + get_tree().node_added.connect(_on_node_added) + call_deferred("_initialize_javascript_bridge") + call_deferred("_decorate_existing_inputs") + +func _process(_delta: float) -> void: + if not _bridge_initialized: + _initialize_javascript_bridge() + +func _initialize_javascript_bridge() -> void: + if _bridge_initialized or OS.get_name() != "Web": + return + var window: Variant = JavaScriptBridge.get_interface("window") + if window == null: + return + _javascript_callback = JavaScriptBridge.create_callback(_on_native_input_event) + window["__whaletownNativeInputCallback"] = _javascript_callback + _bridge_initialized = true + +func _exit_tree() -> void: + _close_native_input() + if _bridge_initialized: + var window: Variant = JavaScriptBridge.get_interface("window") + if window != null: + window["__whaletownNativeInputCallback"] = null + +func _on_node_added(node: Node) -> void: + if node is LineEdit or node is TextEdit: + _decorate_input.call_deferred(node) + +func _decorate_existing_inputs() -> void: + _decorate_branch(get_tree().root) + +func _decorate_branch(node: Node) -> void: + if node is LineEdit or node is TextEdit: + _decorate_input(node) + for child in node.get_children(): + _decorate_branch(child) + +func _decorate_input(input: Control) -> void: + if not is_instance_valid(input) or input.has_meta(STYLED_META): + return + input.set_meta(STYLED_META, true) + input.add_theme_stylebox_override("focus", _create_focus_style(input)) + input.add_theme_color_override("caret_color", CARET_COLOR) + input.add_theme_color_override("selection_color", SELECTION_COLOR) + input.add_theme_constant_override("caret_width", 2) + input.set_meta(INITIAL_PLACEHOLDER_META, input.placeholder_text) + input.focus_entered.connect(_on_input_focus_entered.bind(input)) + input.focus_exited.connect(_on_input_focus_exited.bind(input)) + if input is LineEdit: + (input as LineEdit).text_changed.connect(func(_value: String) -> void: _sync_native_input(input)) + else: + (input as TextEdit).text_changed.connect(func() -> void: _sync_native_input(input)) + +func _on_input_focus_entered(input: Control) -> void: + if not is_instance_valid(input): + return + if input.text.strip_edges().is_empty(): + input.set_meta(FOCUS_PLACEHOLDER_META, input.placeholder_text) + input.placeholder_text = "" + _open_native_input(input) + +func _on_input_focus_exited(input: Control) -> void: + if not is_instance_valid(input) or not input.text.strip_edges().is_empty(): + return + var placeholder := str(input.get_meta(FOCUS_PLACEHOLDER_META, input.get_meta(INITIAL_PLACEHOLDER_META, ""))) + if not placeholder.is_empty(): + input.placeholder_text = placeholder + +func _open_native_input(input: Control) -> void: + if not _bridge_initialized or not is_instance_valid(input): + return + _close_native_input() + _active_native_input = input + _native_inputs[input.get_instance_id()] = input + var callback_name := "window.__whaletownNativeInputCallback" + var input_id := str(input.get_instance_id()) + var initial_text: String = input.text + var multiline := input is TextEdit + var secret := input is LineEdit and (input as LineEdit).secret + var rect := input.get_global_rect() + var viewport_size := get_viewport().get_visible_rect().size + var script := """ +(() => { + const callback = %s; + if (typeof callback !== 'function') return false; + const previous = document.getElementById('%s'); + if (previous) previous.remove(); + const target = document.createElement(%s ? 'textarea' : 'input'); + target.id = '%s'; + target.value = %s; + target.spellcheck = false; + target.autocomplete = 'off'; + if (%s) target.type = 'password'; + const rect = {x: %f, y: %f, width: %f, height: %f}; + const viewport = {width: %f, height: %f}; + const canvas = document.getElementById('canvas'); + const position = () => { + const bounds = canvas.getBoundingClientRect(); + const sx = bounds.width / viewport.width; + const sy = bounds.height / viewport.height; + target.style.left = (bounds.left + rect.x * sx) + 'px'; + target.style.top = (bounds.top + rect.y * sy) + 'px'; + target.style.width = (rect.width * sx) + 'px'; + target.style.height = (rect.height * sy) + 'px'; + }; + Object.assign(target.style, { + position: 'fixed', zIndex: '10000', + opacity: '0.01', color: 'transparent', caretColor: 'transparent', + background: 'transparent', border: '0', outline: '0', padding: '0', + margin: '0', resize: 'none', WebkitTextFillColor: 'transparent' + }); + const send = (kind) => callback(kind, %s, target.value, + target.selectionStart || 0, target.selectionEnd || 0); + target.addEventListener('input', () => send('input')); + target.addEventListener('blur', () => send('blur')); + target.addEventListener('keydown', (event) => { + if (!%s && event.key === 'Enter') { event.preventDefault(); send('submit'); } + if (event.key === 'Escape') { event.preventDefault(); send('cancel'); } + }); + document.querySelectorAll('.ime').forEach((element) => element.remove()); + document.body.appendChild(target); + position(); + window.addEventListener('resize', position, {passive: true}); + target.__whaletownPosition = position; + target.focus({preventScroll: true}); + target.setSelectionRange(target.value.length, target.value.length); + return true; +})() +""" % [callback_name, NATIVE_INPUT_ID, str(multiline).to_lower(), NATIVE_INPUT_ID, + JSON.stringify(initial_text), str(secret).to_lower(), rect.position.x, rect.position.y, + rect.size.x, rect.size.y, viewport_size.x, viewport_size.y, JSON.stringify(input_id), str(multiline).to_lower()] + var opened: Variant = JavaScriptBridge.eval(script, true) + if opened != true: + _native_inputs.erase(input.get_instance_id()) + _active_native_input = null + +func _close_native_input() -> void: + if _bridge_initialized: + JavaScriptBridge.eval("(() => { const el = document.getElementById('%s'); if (el) { if (el.__whaletownPosition) window.removeEventListener('resize', el.__whaletownPosition); el.remove(); } })()" % NATIVE_INPUT_ID, true) + _native_inputs.clear() + _active_native_input = null + +func _sync_native_input(input: Control) -> void: + if not _bridge_initialized or input != _active_native_input or not is_instance_valid(input): + return + var value: String = input.text + var script := "(() => { const el = document.getElementById('%s'); if (el && el.value !== %s) el.value = %s; })()" % [NATIVE_INPUT_ID, JSON.stringify(value), JSON.stringify(value)] + JavaScriptBridge.eval(script, true) + +func _on_native_input_event(arguments: Array) -> void: + if arguments.size() < 3: + return + var event_kind := str(arguments[0]) + var input_id := int(arguments[1]) + var input := _native_inputs.get(input_id) as Control + if not is_instance_valid(input): + return + var value := str(arguments[2]) + if input.text != value: + input.text = value + if input is LineEdit: + (input as LineEdit).set_caret_column(value.length()) + match event_kind: + "submit": + if input is LineEdit: + (input as LineEdit).text_submitted.emit(input.text) + "cancel": + _close_native_input() + +func _create_focus_style(input: Control) -> StyleBoxFlat: + var focusStyle := StyleBoxFlat.new() + focusStyle.bg_color = Color.TRANSPARENT + focusStyle.border_color = FOCUS_BORDER_COLOR + focusStyle.set_border_width_all(2) + + var normalStyle := input.get_theme_stylebox("normal") as StyleBoxFlat + if normalStyle != null: + focusStyle.corner_radius_top_left = normalStyle.corner_radius_top_left + focusStyle.corner_radius_top_right = normalStyle.corner_radius_top_right + focusStyle.corner_radius_bottom_right = normalStyle.corner_radius_bottom_right + focusStyle.corner_radius_bottom_left = normalStyle.corner_radius_bottom_left + else: + focusStyle.set_corner_radius_all(6) + return focusStyle diff --git a/_Core/managers/InputFocusManager.gd.uid b/_Core/managers/InputFocusManager.gd.uid new file mode 100644 index 0000000..c573884 --- /dev/null +++ b/_Core/managers/InputFocusManager.gd.uid @@ -0,0 +1 @@ +uid://lg8ud3kgjliv diff --git a/_Core/managers/SceneManager.gd b/_Core/managers/SceneManager.gd index 9d54b6c..f414771 100644 --- a/_Core/managers/SceneManager.gd +++ b/_Core/managers/SceneManager.gd @@ -32,6 +32,7 @@ signal scene_changed(scene_name: String) signal scene_change_started(scene_name: String) signal scene_pack_progress(scene_name: String, downloaded_bytes: int, total_bytes: int) signal scene_pack_failed(scene_name: String, message: String) +signal scene_pack_load_finished(pack_key: String, success: bool) # ============ 成员变量 ============ @@ -42,14 +43,18 @@ var _next_scene_position: Variant = null # 下一个场景的初始 var _next_spawn_name: String = "" # 下一个场景的出生点名称 (String) var _pack_manifest: Dictionary = {} var _loaded_scene_packs: Dictionary = {} +var _loading_scene_packs: Dictionary = {} +var _scene_pack_prefetch_queue: Array[String] = [] +var _scene_pack_prefetch_running: bool = false var _active_pack_request: HTTPRequest var _active_pack_scene_name: String = "" +var _active_pack_expected_size: int = 0 var _pack_overlay: CanvasLayer var _pack_status_label: Label var _pack_progress_bar: ProgressBar var _web_pack_progress_last_emit_msec: int = 0 -const PACK_MANIFEST_PATH: String = "/packs/manifest.json" +const PACK_MANIFEST_PATH: String = "packs/manifest.json" const PACK_CACHE_DIR: String = "user://scene-packs" const SCENE_PACK_KEYS: Dictionary = { "main": "auth", @@ -61,6 +66,14 @@ const SCENE_PACK_KEYS: Dictionary = { "personal_space": "personal_space", } +# 只预取当前地图可以直接到达的区域。 +# 顺序很重要:登录进入广场后先准备个人空间,再准备打工区; +# 咖啡馆只有在进入打工区后才加入队列。 +const SCENE_PREFETCH_ROUTES: Dictionary = { + "square": ["personal_space", "work_zone"], + "work_zone": ["cafe_interior"], +} + # 场景路径映射表 # 将场景名称映射到实际的文件路径 # 便于统一管理和修改场景路径 @@ -142,6 +155,7 @@ func change_scene(scene_name: String, use_transition: bool = true): current_scene_name = scene_name is_changing_scene = false scene_changed.emit(scene_name) + _schedule_scene_prefetch(scene_name) # 隐藏过渡效果 if use_transition: @@ -154,13 +168,15 @@ func _process(_delta: float) -> void: return var downloaded := _active_pack_request.get_downloaded_bytes() var total := _active_pack_request.get_body_size() + if total <= 0: + total = _active_pack_expected_size if is_instance_valid(_pack_progress_bar): _pack_progress_bar.indeterminate = total <= 0 if total > 0: _pack_progress_bar.max_value = total _pack_progress_bar.value = downloaded if is_instance_valid(_pack_status_label): - _pack_status_label.text = "正在加载场景 %s" % _format_download_progress(downloaded, total) + _pack_status_label.text = _format_scene_loading_status(downloaded, total) _notify_web_auth_pack_progress(downloaded, total) scene_pack_progress.emit(_active_pack_scene_name, downloaded, total) @@ -181,44 +197,105 @@ func _ensure_scene_pack(scene_name: String) -> bool: if packKey.is_empty() or bool(_loaded_scene_packs.get(packKey, false)): return true _show_pack_overlay("正在准备场景...") + # 场景切换也进入同一个预取队列,避免用户点击入口时与后台下载并发。 + _queue_scene_pack_prefetch([scene_name]) + while not bool(_loaded_scene_packs.get(packKey, false)): + if not bool(_loading_scene_packs.get(packKey, false)) and not _scene_pack_prefetch_queue.has(scene_name): + break + await scene_pack_load_finished + if bool(_loaded_scene_packs.get(packKey, false)): + _hide_pack_overlay() + return true + _show_pack_error(scene_name, "场景下载失败,请检查网络后重试") + return false + +func preload_scene_pack(scene_name: String) -> void: + if OS.get_name() != "Web": + return + _queue_scene_pack_prefetch([scene_name]) + +func _schedule_scene_prefetch(scene_name: String) -> void: + if OS.get_name() != "Web" or not _should_prefetch_scene_packs(): + return + var routeVariant: Variant = SCENE_PREFETCH_ROUTES.get(scene_name, []) + if routeVariant is Array and not (routeVariant as Array).is_empty(): + _queue_scene_pack_prefetch(routeVariant as Array) + +func _should_prefetch_scene_packs() -> bool: + var authManager := get_node_or_null("/root/AuthManager") + if authManager == null or not authManager.has_method("is_authenticated"): + return false + if not bool(authManager.call("is_authenticated")): + return false + var chatManager := get_node_or_null("/root/ChatManager") + return chatManager == null or not chatManager.has_method("is_guest_mode") or not bool(chatManager.call("is_guest_mode")) + +func _queue_scene_pack_prefetch(scene_names: Array) -> void: + for sceneNameVariant in scene_names: + var sceneName := str(sceneNameVariant).strip_edges() + var packKey := str(SCENE_PACK_KEYS.get(sceneName, "")) + if packKey.is_empty() or bool(_loaded_scene_packs.get(packKey, false)) or bool(_loading_scene_packs.get(packKey, false)): + continue + if _scene_pack_prefetch_queue.has(sceneName): + continue + _scene_pack_prefetch_queue.append(sceneName) + _run_scene_pack_prefetch_queue() + +func _run_scene_pack_prefetch_queue() -> void: + if _scene_pack_prefetch_running: + return + _scene_pack_prefetch_running = true + while not _scene_pack_prefetch_queue.is_empty(): + var sceneName: String = str(_scene_pack_prefetch_queue.pop_front()) + var packKey := str(SCENE_PACK_KEYS.get(sceneName, "")) + if packKey.is_empty() or bool(_loaded_scene_packs.get(packKey, false)): + continue + if not bool(_loading_scene_packs.get(packKey, false)): + _start_scene_pack_load(sceneName, packKey) + while bool(_loading_scene_packs.get(packKey, false)): + await scene_pack_load_finished + _scene_pack_prefetch_running = false + +func _start_scene_pack_load(scene_name: String, pack_key: String) -> void: + _loading_scene_packs[pack_key] = true + _load_scene_pack_task(scene_name, pack_key) + +func _load_scene_pack_task(scene_name: String, pack_key: String) -> void: + var success := await _load_scene_pack_file(scene_name, pack_key) + if success: + _loaded_scene_packs[pack_key] = true + _loading_scene_packs.erase(pack_key) + scene_pack_load_finished.emit(pack_key, success) + +func _load_scene_pack_file(scene_name: String, pack_key: String) -> bool: if _pack_manifest.is_empty(): _pack_manifest = await _download_pack_manifest() if _pack_manifest.is_empty(): - _show_pack_error(scene_name, "场景清单加载失败,请检查网络后重试") return false - var entryVariant: Variant = _pack_manifest.get(packKey, {}) + var entryVariant: Variant = _pack_manifest.get(pack_key, {}) if not (entryVariant is Dictionary): - _show_pack_error(scene_name, "服务器缺少场景资源:%s" % packKey) return false var entry := entryVariant as Dictionary var fileName := str(entry.get("file", "")).get_file() if fileName.is_empty() or not fileName.ends_with(".pck"): - _show_pack_error(scene_name, "场景资源清单格式错误") return false var expectedSize := int(entry.get("size", 0)) var cacheDirAbsolute := ProjectSettings.globalize_path(PACK_CACHE_DIR) DirAccess.make_dir_recursive_absolute(cacheDirAbsolute) var localPath := "%s/%s" % [PACK_CACHE_DIR, fileName] if _is_scene_pack_file_valid(localPath, fileName, expectedSize) and ProjectSettings.load_resource_pack(localPath, true): - _loaded_scene_packs[packKey] = true - _hide_pack_overlay() return true if FileAccess.file_exists(localPath): DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath)) - var packUrl := _resolve_web_url("/packs/%s" % fileName) - if packUrl.is_empty() or not await _download_pack(scene_name, packUrl, localPath): - _show_pack_error(scene_name, "场景下载失败,请检查网络后重试") + var packUrl := _resolve_web_url("packs/%s" % fileName) + if packUrl.is_empty() or not await _download_pack(scene_name, packUrl, localPath, expectedSize): return false if not _is_scene_pack_file_valid(localPath, fileName, expectedSize): DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath)) - _show_pack_error(scene_name, "场景资源下载不完整,请重新进入") return false if not ProjectSettings.load_resource_pack(localPath, true): DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath)) - _show_pack_error(scene_name, "场景资源损坏,请重新进入") return false - _loaded_scene_packs[packKey] = true - _hide_pack_overlay() return true func _is_scene_pack_file_valid(local_path: String, file_name: String, expected_size: int) -> bool: @@ -265,21 +342,24 @@ func _download_pack_manifest() -> Dictionary: var packsVariant: Variant = root.get("packs", {}) return packsVariant as Dictionary if packsVariant is Dictionary else {} -func _download_pack(scene_name: String, url: String, local_path: String) -> bool: +func _download_pack(scene_name: String, url: String, local_path: String, expected_size: int) -> bool: var request := HTTPRequest.new() - request.timeout = 180.0 + request.timeout = 900.0 request.download_file = local_path add_child(request) _active_pack_request = request _active_pack_scene_name = scene_name + _active_pack_expected_size = expected_size if request.request(url) != OK: _active_pack_request = null _active_pack_scene_name = "" + _active_pack_expected_size = 0 request.queue_free() return false var response: Array = await request.request_completed _active_pack_request = null _active_pack_scene_name = "" + _active_pack_expected_size = 0 request.queue_free() if response.size() < 2 or int(response[0]) != HTTPRequest.RESULT_SUCCESS: return false @@ -343,6 +423,12 @@ func _format_download_progress(downloaded: int, total: int) -> String: return "%.1f MB" % downloadedMb return "%.1f / %.1f MB" % [downloadedMb, float(total) / 1048576.0] +func _format_scene_loading_status(downloaded: int, total: int) -> String: + if total <= 0: + return "正在加载场景..." + var percent: int = mini(100, int(round(float(downloaded) / float(total) * 100.0))) + return "正在加载场景 %d%%(%s)" % [percent, _format_download_progress(downloaded, total)] + # ============ 查询方法 ============ # 获取当前场景名称 diff --git a/_Core/managers/WebSocketManager.gd b/_Core/managers/WebSocketManager.gd index ff60cab..c2ae48c 100644 --- a/_Core/managers/WebSocketManager.gd +++ b/_Core/managers/WebSocketManager.gd @@ -69,7 +69,7 @@ enum ConnectionState { # ============================================================================ # WebSocket 服务器 URL(原生 WebSocket) -const WEBSOCKET_URL: String = "wss://whaletownend.xinghangee.icu/game" +const WEBSOCKET_URL: String = "wss://whaletown.novamailio.com/game" # 默认最大重连次数 const DEFAULT_MAX_RECONNECT_ATTEMPTS: int = 5 @@ -119,6 +119,9 @@ var _heartbeat_timer: Timer # 心跳间隔(秒) const HEARTBEAT_INTERVAL: float = 30.0 +const HEARTBEAT_TIMEOUT: float = 75.0 + +var _last_server_message_at_msec: int = 0 # ============================================================================ # 生命周期方法 @@ -149,6 +152,7 @@ func _process(_delta: float) -> void: while _websocket_peer.get_available_packet_count() > 0: var packet: PackedByteArray = _websocket_peer.get_packet() var message: String = packet.get_string_from_utf8() + _last_server_message_at_msec = Time.get_ticks_msec() # 发射消息接收信号 data_received.emit(message) @@ -194,9 +198,6 @@ func connect_to_game_server(is_reconnect_attempt: bool = false) -> void: _set_connection_state(ConnectionState.ERROR) return - # 启动心跳 - _start_heartbeat() - # 断开 WebSocket 连接 func disconnect_websocket() -> void: _disconnect() @@ -358,9 +359,12 @@ func _on_websocket_connected() -> void: reconnection_succeeded.emit() _set_connection_state(ConnectionState.CONNECTED) + _last_server_message_at_msec = Time.get_ticks_msec() + _start_heartbeat() # WebSocket 连接关闭处理 func _on_websocket_closed() -> void: + _stop_heartbeat() if not _manual_disconnect_requested and _auto_reconnect_enabled: connection_lost.emit() _attempt_reconnect() @@ -382,13 +386,6 @@ func _setup_reconnect_timer() -> void: # 尝试重连 func _attempt_reconnect() -> void: - # 检查是否超过最大重连次数 - if _reconnect_attempt >= _max_reconnect_attempts: - push_error("WebSocketManager: 达到最大重连次数 (%d),停止重连" % _max_reconnect_attempts) - reconnection_failed.emit(_reconnect_attempt, _max_reconnect_attempts) - _set_connection_state(ConnectionState.ERROR) - return - _reconnect_attempt += 1 _set_connection_state(ConnectionState.RECONNECTING) @@ -401,7 +398,8 @@ func _attempt_reconnect() -> void: # 计算重连延迟(指数退避) func _calculate_reconnect_delay() -> float: # 指数退避: base_delay * 2^(attempt-1) - var delay: float = _reconnect_base_delay * pow(2.0, _reconnect_attempt - 1) + var cappedAttempt := mini(_reconnect_attempt, maxi(_max_reconnect_attempts, 1)) + var delay: float = _reconnect_base_delay * pow(2.0, cappedAttempt - 1) # 限制最大延迟 return min(delay, MAX_RECONNECT_DELAY) @@ -434,9 +432,16 @@ func _stop_heartbeat() -> void: # 心跳超时处理 func _on_heartbeat() -> void: - # 不发送心跳,避免服务器返回 "消息格式错误" - # 如果需要心跳,服务器应该支持特定格式 - pass + if _websocket_peer.get_ready_state() != WebSocketPeer.STATE_OPEN: + return + var now := Time.get_ticks_msec() + if _last_server_message_at_msec > 0 and now - _last_server_message_at_msec > int(HEARTBEAT_TIMEOUT * 1000.0): + push_warning("WebSocketManager: 心跳超时,正在重新连接") + _websocket_peer.close(4000, "Heartbeat timeout") + return + var err := _websocket_peer.send_text(JSON.stringify({"type": "ping"})) + if err != OK: + push_warning("WebSocketManager: 心跳发送失败 - %s" % error_string(err)) # ============================================================================ # 工具方法 @@ -455,7 +460,7 @@ func get_state_description() -> String: ConnectionState.CONNECTED: return "已连接" ConnectionState.RECONNECTING: - return "重连中 (%d/%d)" % [_reconnect_attempt, _max_reconnect_attempts] + return "重连中 (第 %d 次)" % _reconnect_attempt ConnectionState.ERROR: return "错误" _: diff --git a/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_down.png b/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_down.png new file mode 100644 index 0000000..cc04dc4 Binary files /dev/null and b/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_down.png differ diff --git a/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_down.png.import b/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_down.png.import new file mode 100644 index 0000000..fd67f11 --- /dev/null +++ b/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_down.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dku0dqqhtnjbk" +path="res://.godot/imported/horned_creature_npc_idle_down.png-fae6e26bf555f707761f7ec796a16f56.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_down.png" +dest_files=["res://.godot/imported/horned_creature_npc_idle_down.png-fae6e26bf555f707761f7ec796a16f56.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_left.png b/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_left.png new file mode 100644 index 0000000..2de4efb Binary files /dev/null and b/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_left.png differ diff --git a/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_left.png.import b/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_left.png.import new file mode 100644 index 0000000..a5da220 --- /dev/null +++ b/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_left.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bgwcow6lkrb6c" +path="res://.godot/imported/horned_creature_npc_idle_left.png-80f388f72940bd5985d973d135215c97.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_left.png" +dest_files=["res://.godot/imported/horned_creature_npc_idle_left.png-80f388f72940bd5985d973d135215c97.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_right.png b/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_right.png new file mode 100644 index 0000000..b4e9491 Binary files /dev/null and b/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_right.png differ diff --git a/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_right.png.import b/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_right.png.import new file mode 100644 index 0000000..b0cedae --- /dev/null +++ b/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_right.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cmf8t7qsogtkv" +path="res://.godot/imported/horned_creature_npc_idle_right.png-98efb444bb786263b2f3dd1d1241be34.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_right.png" +dest_files=["res://.godot/imported/horned_creature_npc_idle_right.png-98efb444bb786263b2f3dd1d1241be34.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_up.png b/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_up.png new file mode 100644 index 0000000..78509f6 Binary files /dev/null and b/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_up.png differ diff --git a/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_up.png.import b/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_up.png.import new file mode 100644 index 0000000..0b080be --- /dev/null +++ b/assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_up.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bv70hix5m85sf" +path="res://.godot/imported/horned_creature_npc_idle_up.png-aa780407ff47d0ba8647dab1272956e4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_up.png" +dest_files=["res://.godot/imported/horned_creature_npc_idle_up.png-aa780407ff47d0ba8647dab1272956e4.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/characters/generated/horned_creature_npc/horned_creature_npc_spritesheet.png b/assets/characters/generated/horned_creature_npc/horned_creature_npc_spritesheet.png new file mode 100644 index 0000000..a7b3e0d Binary files /dev/null and b/assets/characters/generated/horned_creature_npc/horned_creature_npc_spritesheet.png differ diff --git a/assets/characters/generated/horned_creature_npc/horned_creature_npc_spritesheet.png.import b/assets/characters/generated/horned_creature_npc/horned_creature_npc_spritesheet.png.import new file mode 100644 index 0000000..51d9a04 --- /dev/null +++ b/assets/characters/generated/horned_creature_npc/horned_creature_npc_spritesheet.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b8ogjmo7jx5x5" +path="res://.godot/imported/horned_creature_npc_spritesheet.png-443e96d87eae79edde2818f42c4ec378.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/characters/generated/horned_creature_npc/horned_creature_npc_spritesheet.png" +dest_files=["res://.godot/imported/horned_creature_npc_spritesheet.png-443e96d87eae79edde2818f42c4ec378.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/characters/generated/whale_researcher_v2/final_no_feet/processed/whale_researcher_no_feet_spritesheet.png b/assets/characters/generated/whale_researcher_v2/final_no_feet/processed/whale_researcher_no_feet_spritesheet.png new file mode 100644 index 0000000..0a51eac Binary files /dev/null and b/assets/characters/generated/whale_researcher_v2/final_no_feet/processed/whale_researcher_no_feet_spritesheet.png differ diff --git a/assets/characters/generated/whale_researcher_v2/final_no_feet/processed/whale_researcher_no_feet_spritesheet.png.import b/assets/characters/generated/whale_researcher_v2/final_no_feet/processed/whale_researcher_no_feet_spritesheet.png.import new file mode 100644 index 0000000..25b3fde --- /dev/null +++ b/assets/characters/generated/whale_researcher_v2/final_no_feet/processed/whale_researcher_no_feet_spritesheet.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://i2qjcms43oc0" +path="res://.godot/imported/whale_researcher_no_feet_spritesheet.png-828a36866400f9adbedeaaedec6e718f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/characters/generated/whale_researcher_v2/final_no_feet/processed/whale_researcher_no_feet_spritesheet.png" +dest_files=["res://.godot/imported/whale_researcher_no_feet_spritesheet.png-828a36866400f9adbedeaaedec6e718f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/characters/skins/girl_sailor_turnaround_v2_8x4_spritesheet.png b/assets/characters/skins/girl_sailor_turnaround_v2_8x4_spritesheet.png index 40bfc4c..6e4f31c 100644 Binary files a/assets/characters/skins/girl_sailor_turnaround_v2_8x4_spritesheet.png and b/assets/characters/skins/girl_sailor_turnaround_v2_8x4_spritesheet.png differ diff --git a/assets/fonts/fusion-pixel-12px/OFL.txt b/assets/fonts/fusion-pixel-12px/OFL.txt new file mode 100644 index 0000000..0427d1d --- /dev/null +++ b/assets/fonts/fusion-pixel-12px/OFL.txt @@ -0,0 +1,96 @@ +Fusion Pixel Font +https://github.com/TakWolf/fusion-pixel-font + +Copyright (c) 2022, TakWolf (https://takwolf.com). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2 b/assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2 new file mode 100644 index 0000000..6d312c8 Binary files /dev/null and b/assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2 differ diff --git a/assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2.import b/assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2.import new file mode 100644 index 0000000..db8303d --- /dev/null +++ b/assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://dss1envknmwvy" +path="res://.godot/imported/fusion-pixel-12px-proportional-latin.ttf.woff2-d7baa0634e626d804d8eea7c4b5e5637.fontdata" + +[deps] + +source_file="res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2" +dest_files=["res://.godot/imported/fusion-pixel-12px-proportional-latin.ttf.woff2-d7baa0634e626d804d8eea7c4b5e5637.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=1 +subpixel_positioning=4 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2 b/assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2 new file mode 100644 index 0000000..7e98ec2 Binary files /dev/null and b/assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2 differ diff --git a/assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2.import b/assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2.import new file mode 100644 index 0000000..b8ff671 --- /dev/null +++ b/assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://cea60eanpeue4" +path="res://.godot/imported/fusion-pixel-12px-proportional-zh_hans.ttf.woff2-3005ce2bd956afbf78a39ebd1102f9dc.fontdata" + +[deps] + +source_file="res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2" +dest_files=["res://.godot/imported/fusion-pixel-12px-proportional-zh_hans.ttf.woff2-3005ce2bd956afbf78a39ebd1102f9dc.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=1 +subpixel_positioning=4 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/assets/shaders/avatar_round_mask.gdshader b/assets/shaders/avatar_round_mask.gdshader new file mode 100644 index 0000000..fafedb1 --- /dev/null +++ b/assets/shaders/avatar_round_mask.gdshader @@ -0,0 +1,15 @@ +shader_type canvas_item; + +uniform float corner_radius = 0.22; +uniform float edge_feather = 0.008; + +void fragment() { + vec2 q = abs(UV - vec2(0.5)) - (vec2(0.5) - vec2(corner_radius)); + float outside_distance = length(max(q, vec2(0.0))); + float inside_distance = min(max(q.x, q.y), 0.0); + float signed_distance = outside_distance + inside_distance - corner_radius; + float mask = 1.0 - smoothstep(0.0, edge_feather, signed_distance); + + COLOR = texture(TEXTURE, UV); + COLOR.a *= mask; +} diff --git a/assets/shaders/avatar_round_mask.gdshader.uid b/assets/shaders/avatar_round_mask.gdshader.uid new file mode 100644 index 0000000..07e3dde --- /dev/null +++ b/assets/shaders/avatar_round_mask.gdshader.uid @@ -0,0 +1 @@ +uid://bkudybm8sbvug diff --git a/assets/ui/mall/skins/girl_sailor_turnaround_v2_8x4_preview.png b/assets/ui/mall/skins/girl_sailor_turnaround_v2_8x4_preview.png index f208aec..55a929a 100644 Binary files a/assets/ui/mall/skins/girl_sailor_turnaround_v2_8x4_preview.png and b/assets/ui/mall/skins/girl_sailor_turnaround_v2_8x4_preview.png differ diff --git a/assets/ui/mall/skins/girl_sailor_turnaround_v2_8x4_product.png b/assets/ui/mall/skins/girl_sailor_turnaround_v2_8x4_product.png index d922f01..36617b9 100644 Binary files a/assets/ui/mall/skins/girl_sailor_turnaround_v2_8x4_product.png and b/assets/ui/mall/skins/girl_sailor_turnaround_v2_8x4_product.png differ diff --git a/assets/ui/world_bulletin/community/empty_whale.png b/assets/ui/world_bulletin/community/empty_whale.png new file mode 100644 index 0000000..d0f8be4 Binary files /dev/null and b/assets/ui/world_bulletin/community/empty_whale.png differ diff --git a/assets/ui/world_bulletin/community/empty_whale.png.import b/assets/ui/world_bulletin/community/empty_whale.png.import new file mode 100644 index 0000000..6b6e3ce --- /dev/null +++ b/assets/ui/world_bulletin/community/empty_whale.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://eq2fin4oby2k" +path="res://.godot/imported/empty_whale.png-ffea64aa04d74a02a6935a88182cee3c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/world_bulletin/community/empty_whale.png" +dest_files=["res://.godot/imported/empty_whale.png-ffea64aa04d74a02a6935a88182cee3c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=true +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=true +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/world_bulletin/community/pinned_note.png b/assets/ui/world_bulletin/community/pinned_note.png new file mode 100644 index 0000000..5da9770 Binary files /dev/null and b/assets/ui/world_bulletin/community/pinned_note.png differ diff --git a/assets/ui/world_bulletin/community/pinned_note.png.import b/assets/ui/world_bulletin/community/pinned_note.png.import new file mode 100644 index 0000000..dbe5c12 --- /dev/null +++ b/assets/ui/world_bulletin/community/pinned_note.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bx5uy1p64o4vo" +path="res://.godot/imported/pinned_note.png-034bf127b63da4f239b070c415812df8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/world_bulletin/community/pinned_note.png" +dest_files=["res://.godot/imported/pinned_note.png-034bf127b63da4f239b070c415812df8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=true +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=true +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/world_bulletin/community/recruit_whales.png b/assets/ui/world_bulletin/community/recruit_whales.png new file mode 100644 index 0000000..0d65ff5 Binary files /dev/null and b/assets/ui/world_bulletin/community/recruit_whales.png differ diff --git a/assets/ui/world_bulletin/community/recruit_whales.png.import b/assets/ui/world_bulletin/community/recruit_whales.png.import new file mode 100644 index 0000000..d4010bc --- /dev/null +++ b/assets/ui/world_bulletin/community/recruit_whales.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b0w5mutlqnlxw" +path="res://.godot/imported/recruit_whales.png-d87526a4f19c9d60855712b106907786.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/world_bulletin/community/recruit_whales.png" +dest_files=["res://.godot/imported/recruit_whales.png-d87526a4f19c9d60855712b106907786.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=true +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=true +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/world_bulletin/community/town_banner.png b/assets/ui/world_bulletin/community/town_banner.png new file mode 100644 index 0000000..af7d310 Binary files /dev/null and b/assets/ui/world_bulletin/community/town_banner.png differ diff --git a/assets/ui/world_bulletin/community/town_banner.png.import b/assets/ui/world_bulletin/community/town_banner.png.import new file mode 100644 index 0000000..9314cd8 --- /dev/null +++ b/assets/ui/world_bulletin/community/town_banner.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bk6gkltoavsd" +path="res://.godot/imported/town_banner.png-ee7512df5a2c66f421bc751ceff6e080.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/world_bulletin/community/town_banner.png" +dest_files=["res://.godot/imported/town_banner.png-ee7512df5a2c66f421bc751ceff6e080.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=true +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=true +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/world_bulletin/community/world_bulletin_icon_hd_simple_tight.png b/assets/ui/world_bulletin/community/world_bulletin_icon_hd_simple_tight.png new file mode 100644 index 0000000..399cb64 Binary files /dev/null and b/assets/ui/world_bulletin/community/world_bulletin_icon_hd_simple_tight.png differ diff --git a/assets/ui/world_bulletin/community/world_bulletin_icon_hd_simple_tight.png.import b/assets/ui/world_bulletin/community/world_bulletin_icon_hd_simple_tight.png.import new file mode 100644 index 0000000..3ae7c54 --- /dev/null +++ b/assets/ui/world_bulletin/community/world_bulletin_icon_hd_simple_tight.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://xpkmqb81fjw6" +path="res://.godot/imported/world_bulletin_icon_hd_simple_tight.png-b97116e82ce39ccf8dbc8ba2be158537.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/world_bulletin/community/world_bulletin_icon_hd_simple_tight.png" +dest_files=["res://.godot/imported/world_bulletin_icon_hd_simple_tight.png-b97116e82ce39ccf8dbc8ba2be158537.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=true +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=true +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/world_bulletin/world_bulletin_send_button_final_192.png b/assets/ui/world_bulletin/world_bulletin_send_button_final_192.png new file mode 100644 index 0000000..b59e174 Binary files /dev/null and b/assets/ui/world_bulletin/world_bulletin_send_button_final_192.png differ diff --git a/assets/ui/world_bulletin/world_bulletin_send_button_final_192.png.import b/assets/ui/world_bulletin/world_bulletin_send_button_final_192.png.import new file mode 100644 index 0000000..f1879e2 --- /dev/null +++ b/assets/ui/world_bulletin/world_bulletin_send_button_final_192.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://s42gvnli8h2e" +path="res://.godot/imported/world_bulletin_send_button_final_192.png-8db1899e79e2361bfc869a0da3dc8d51.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/world_bulletin/world_bulletin_send_button_final_192.png" +dest_files=["res://.godot/imported/world_bulletin_send_button_final_192.png-8db1899e79e2361bfc869a0da3dc8d51.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=true +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=true +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/world_bulletin/world_bulletin_send_v2_128.png b/assets/ui/world_bulletin/world_bulletin_send_v2_128.png new file mode 100644 index 0000000..ce02a48 Binary files /dev/null and b/assets/ui/world_bulletin/world_bulletin_send_v2_128.png differ diff --git a/assets/ui/world_bulletin/world_bulletin_send_v2_128.png.import b/assets/ui/world_bulletin/world_bulletin_send_v2_128.png.import new file mode 100644 index 0000000..631e55b --- /dev/null +++ b/assets/ui/world_bulletin/world_bulletin_send_v2_128.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://2ukykf5vywnj" +path="res://.godot/imported/world_bulletin_send_v2_128.png-e1f7806cd2d6195a5af01a8fcf36310f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/world_bulletin/world_bulletin_send_v2_128.png" +dest_files=["res://.godot/imported/world_bulletin_send_v2_128.png-e1f7806cd2d6195a5af01a8fcf36310f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/deploy/Caddyfile.example b/deploy/Caddyfile.example new file mode 100644 index 0000000..21a91cc --- /dev/null +++ b/deploy/Caddyfile.example @@ -0,0 +1,24 @@ +whaletown.novamailio.com { + root * /var/www/whale-town-front-v2/build/site + + @playWithoutSlash path /play + @wasm path *.wasm + @pack path *.pck + redir @playWithoutSlash /play/ 308 + + handle_path /play/* { + root * /var/www/whale-town-front-v2/build/site/play + header @wasm Content-Type application/wasm + header @pack Content-Type application/octet-stream + file_server + } + + handle /downloads/* { + header Content-Disposition "attachment" + file_server + } + + handle { + file_server + } +} diff --git a/deploy/Caddyfile.production b/deploy/Caddyfile.production new file mode 100644 index 0000000..80da5ef --- /dev/null +++ b/deploy/Caddyfile.production @@ -0,0 +1,129 @@ +# Existing Sub2API and blog sites on the Singapore host. +api.novamailio.com { + @long_stream path /v1/responses /responses + + handle @long_stream { + reverse_proxy 127.0.0.1:8080 { + flush_interval -1 + stream_close_delay 5m + transport http { + keepalive 30s + keepalive_interval 15s + keepalive_idle_conns_per_host 128 + } + } + } + + handle { + reverse_proxy 127.0.0.1:8080 + } +} + +xiangking.novamailio.com { + root * /var/www/xiangking-blog + encode zstd gzip + + @assets path /assets/* + header @assets Cache-Control "public, max-age=31536000, immutable" + + file_server +} + +whaletown.novamailio.com { + log { + output file /var/log/caddy/whaletown-access.json { + roll_size 100MiB + roll_keep 10 + roll_keep_for 720h + } + format json + } + + encode zstd gzip + root * /var/www/whaletown/site + + header { + X-Content-Type-Options nosniff + Referrer-Policy strict-origin-when-cross-origin + } + + @immutable_pack { + path /play/packs/*.pck + file {path} + } + header @immutable_pack { + Cache-Control "public, max-age=31536000, immutable" + Content-Type "application/octet-stream" + } + + @immutable_admin path /admin/assets/* + header @immutable_admin Cache-Control "public, max-age=31536000, immutable" + + @pack_manifest path /play/packs/manifest.json + header @pack_manifest Cache-Control "no-cache" + + @runtime path /play/index.js /play/index.wasm /play/index-*.pck /play/index.audio*.js + header @runtime Cache-Control "public, max-age=14400" + + @html path / /index.html /play /play/ /play/index.html /admin /admin/ /admin/index.html + header @html Cache-Control "no-cache" + + @play_without_slash path /play + redir @play_without_slash /play/ 308 + + handle_path /play/* { + root * /var/www/whaletown/site/play + file_server + } + + @game path /game* + handle @game { + reverse_proxy http://124.221.77.216 { + header_up Host 124.221.77.216 + } + } + + @location path /location-broadcast + handle @location { + reverse_proxy http://124.221.77.216 { + header_up Host 124.221.77.216 + } + } + + @notice path /ws/notice + handle @notice { + reverse_proxy http://124.221.77.216 { + header_up Host 124.221.77.216 + } + } + + @account_assets path /assets/account/* + handle @account_assets { + reverse_proxy http://124.221.77.216 { + header_up Host 124.221.77.216 + } + } + + redir /api /api/ + handle /api/* { + reverse_proxy http://124.221.77.216 { + header_up Host 124.221.77.216 + } + } + + redir /admin /admin/ + handle_path /admin/* { + root * /var/www/whaletown/admin + try_files {path} /index.html + file_server + } + + handle /downloads/* { + header Content-Disposition "attachment" + file_server + } + + handle { + file_server + } +} diff --git a/deploy/Caddyfile.rainyun-novamailio b/deploy/Caddyfile.rainyun-novamailio new file mode 100644 index 0000000..9706e4b --- /dev/null +++ b/deploy/Caddyfile.rainyun-novamailio @@ -0,0 +1,89 @@ +whaletown.novamailio.com { + log { + output file /var/log/caddy/whaletown-access.json { + roll_size 20MiB + roll_keep 5 + roll_keep_for 168h + } + format json + } + + encode zstd gzip + root * /var/www/whaletown/site + + header { + X-Content-Type-Options nosniff + Referrer-Policy strict-origin-when-cross-origin + } + + @play path /play/* + header @play { + Cross-Origin-Opener-Policy same-origin + Cross-Origin-Embedder-Policy require-corp + } + + @immutable_pack { + path /play/packs/*.pck + file {path} + } + header @immutable_pack { + Cache-Control "public, max-age=31536000, immutable" + Content-Type "application/octet-stream" + } + + @pack_manifest path /play/packs/manifest.json + header @pack_manifest Cache-Control "no-cache" + + @runtime path /play/index.js /play/index.wasm /play/index-*.pck /play/index.audio*.js + header @runtime Cache-Control "public, max-age=14400" + + @html path / /index.html /play /play/ /play/index.html + header @html Cache-Control "no-cache" + + @play_without_slash path /play + redir @play_without_slash /play/ 308 + + handle_path /play/* { + root * /var/www/whaletown/site/play + file_server + } + + @game path /game* + handle @game { + reverse_proxy http://124.221.77.216 { + header_up Host 124.221.77.216 + } + } + + @location path /location-broadcast + handle @location { + reverse_proxy http://124.221.77.216 { + header_up Host 124.221.77.216 + } + } + + @notice path /ws/notice + handle @notice { + reverse_proxy http://124.221.77.216 { + header_up Host 124.221.77.216 + } + } + + @account_assets path /assets/account/* + handle @account_assets { + reverse_proxy http://124.221.77.216 { + header_up Host 124.221.77.216 + } + } + + redir /api /api/ + handle /api/* { + reverse_proxy http://124.221.77.216 { + header_up Host 124.221.77.216 + } + } + + handle { + file_server + } +} diff --git a/deploy/Caddyfile.rainyun-test b/deploy/Caddyfile.rainyun-test new file mode 100644 index 0000000..6c259fc --- /dev/null +++ b/deploy/Caddyfile.rainyun-test @@ -0,0 +1,89 @@ +162-251-94-42.sslip.io { + log { + output file /var/log/caddy/whaletown-access.json { + roll_size 20MiB + roll_keep 5 + roll_keep_for 168h + } + format json + } + + encode zstd gzip + root * /var/www/whaletown/site + + header { + X-Content-Type-Options nosniff + Referrer-Policy strict-origin-when-cross-origin + } + + @play path /play/* + header @play { + Cross-Origin-Opener-Policy same-origin + Cross-Origin-Embedder-Policy require-corp + } + + @immutable_pack { + path /play/packs/*.pck + file {path} + } + header @immutable_pack { + Cache-Control "public, max-age=31536000, immutable" + Content-Type "application/octet-stream" + } + + @pack_manifest path /play/packs/manifest.json + header @pack_manifest Cache-Control "no-cache" + + @runtime path /play/index.js /play/index.wasm /play/index-*.pck /play/index.audio*.js + header @runtime Cache-Control "public, max-age=14400" + + @html path / /index.html /play /play/ /play/index.html + header @html Cache-Control "no-cache" + + @play_without_slash path /play + redir @play_without_slash /play/ 308 + + handle_path /play/* { + root * /var/www/whaletown/site/play + file_server + } + + @game path /game* + handle @game { + reverse_proxy http://124.221.77.216 { + header_up Host 124.221.77.216 + } + } + + @location path /location-broadcast + handle @location { + reverse_proxy http://124.221.77.216 { + header_up Host 124.221.77.216 + } + } + + @notice path /ws/notice + handle @notice { + reverse_proxy http://124.221.77.216 { + header_up Host 124.221.77.216 + } + } + + @account_assets path /assets/account/* + handle @account_assets { + reverse_proxy http://124.221.77.216 { + header_up Host 124.221.77.216 + } + } + + redir /api /api/ + handle /api/* { + reverse_proxy http://124.221.77.216 { + header_up Host 124.221.77.216 + } + } + + handle { + file_server + } +} diff --git a/deploy/nginx/whaletown-front-v2.conf.example b/deploy/nginx/whaletown-front-v2.conf.example index bbe83ea..c26b6ed 100644 --- a/deploy/nginx/whaletown-front-v2.conf.example +++ b/deploy/nginx/whaletown-front-v2.conf.example @@ -1,6 +1,6 @@ server { listen 80; - server_name whaletown.xinghangee.icu; + server_name whaletown.novamailio.com; root /var/www/whale-town-front-v2/build/web; index index.html; diff --git a/export_presets.cfg b/export_presets.cfg index 5870c37..2c7d8b4 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -8,7 +8,7 @@ dedicated_server=false custom_features="" export_filter="scenes" export_files=PackedStringArray("res://scenes/ui/WebBootstrap.tscn") -include_filter="Config/*.gd,Config/*.json,_Core/*.gd,_Core/managers/*.gd,_Core/systems/*.gd,_Core/utils/*.gd,assets/audio/ui/*.wav,assets/fonts/msyh-web.ttf" +include_filter="Config/*.gd,Config/*.json,_Core/*.gd,_Core/managers/*.gd,_Core/systems/*.gd,_Core/utils/*.gd,assets/audio/ui/*.wav,assets/fonts/msyh-web.ttf,assets/shaders/*.gdshader" exclude_filter="" export_path="build/web/index.html" patches=PackedStringArray() @@ -53,7 +53,7 @@ dedicated_server=false custom_features="" export_filter="all_resources" include_filter="" -exclude_filter="" +exclude_filter="build/**,output/**,docs/**,scripts/**,tools/**" export_path="build/linux/WhaleTown-V2.x86_64" patches=PackedStringArray() encryption_include_filters="" @@ -84,7 +84,7 @@ dedicated_server=false custom_features="" export_filter="scenes" export_files=PackedStringArray("res://scenes/Maps/square.tscn") -include_filter="scenes/Maps/*.gd,scenes/characters/*.gd,scenes/characters/*.tscn,scenes/prefabs/items/*.gd,scenes/prefabs/items/*.tscn,scenes/prefabs/ui/*.gd,scenes/prefabs/ui/*.tscn,scenes/ui/*.gd,scenes/ui/*.tscn,scenes/ui/mall/*.gd,scenes/ui/mall/*.tscn,assets/audio/ui/*.wav,assets/characters/*.png,assets/characters/skins/*.png,assets/ui/*.tres,assets/ui/auth/generated/*.png,assets/ui/datawhale_honor/*.png,assets/ui/mall/branding/*.png,assets/ui/mall/icons/processed/*.png,assets/ui/mall/items/*.png,assets/ui/mall/skins/*_product.png,assets/ui/settings/*.png" +include_filter="scenes/Maps/*.gd,scenes/characters/*.gd,scenes/characters/*.tscn,scenes/prefabs/items/*.gd,scenes/prefabs/items/*.tscn,scenes/prefabs/ui/*.gd,scenes/prefabs/ui/*.tscn,scenes/ui/*.gd,scenes/ui/*.tscn,scenes/ui/mall/*.gd,scenes/ui/mall/*.tscn,assets/audio/ui/*.wav,assets/characters/*.png,assets/characters/skins/*.png,assets/fonts/fusion-pixel-12px/*.woff2,assets/ui/*.tres,assets/ui/auth/generated/*.png,assets/ui/datawhale_honor/*.png,assets/ui/world_bulletin/*.png,assets/ui/mall/branding/*.png,assets/ui/mall/icons/processed/*.png,assets/ui/mall/items/*.png,assets/ui/mall/skins/*_product.png,assets/ui/settings/*.png" exclude_filter="assets/maps/work_zone/**/*.png,assets/maps/personal_space/**/*.png,assets/maps/cafe/**/*.png" export_path="build/packs/square.pck" patches=PackedStringArray() @@ -112,7 +112,7 @@ dedicated_server=false custom_features="" export_filter="scenes" export_files=PackedStringArray("res://scenes/Maps/work_zone.tscn") -include_filter="scenes/Maps/*.gd,scenes/characters/*.gd,scenes/characters/*.tscn,scenes/prefabs/items/*.gd,scenes/prefabs/items/*.tscn,scenes/prefabs/ui/*.gd,scenes/prefabs/ui/*.tscn,scenes/ui/*.gd,scenes/ui/*.tscn,scenes/ui/mall/*.gd,scenes/ui/mall/*.tscn,assets/characters/*.png,assets/ui/*.tres,assets/ui/auth/generated/*.png,assets/ui/datawhale_honor/*.png" +include_filter="scenes/Maps/*.gd,scenes/characters/*.gd,scenes/characters/*.tscn,scenes/prefabs/items/*.gd,scenes/prefabs/items/*.tscn,scenes/prefabs/ui/*.gd,scenes/prefabs/ui/*.tscn,scenes/ui/*.gd,scenes/ui/*.tscn,scenes/ui/mall/*.gd,scenes/ui/mall/*.tscn,assets/characters/*.png,assets/fonts/fusion-pixel-12px/*.woff2,assets/ui/*.tres,assets/ui/auth/generated/*.png,assets/ui/datawhale_honor/*.png" exclude_filter="assets/maps/square/**/*.png,assets/maps/personal_space/**/*.png,assets/maps/cafe/**/*.png" export_path="build/packs/work_zone.pck" patches=PackedStringArray() @@ -140,7 +140,7 @@ dedicated_server=false custom_features="" export_filter="scenes" export_files=PackedStringArray("res://scenes/Maps/cafe_interior.tscn") -include_filter="scenes/Maps/*.gd,scenes/characters/*.gd,scenes/characters/*.tscn,scenes/prefabs/items/*.gd,scenes/prefabs/items/*.tscn,scenes/prefabs/ui/*.gd,scenes/prefabs/ui/*.tscn,scenes/ui/*.gd,scenes/ui/*.tscn,scenes/ui/mall/*.gd,scenes/ui/mall/*.tscn,assets/characters/*.png,assets/ui/*.tres,assets/ui/auth/generated/*.png,assets/ui/datawhale_honor/*.png" +include_filter="scenes/Maps/*.gd,scenes/characters/*.gd,scenes/characters/*.tscn,scenes/prefabs/items/*.gd,scenes/prefabs/items/*.tscn,scenes/prefabs/ui/*.gd,scenes/prefabs/ui/*.tscn,scenes/ui/*.gd,scenes/ui/*.tscn,scenes/ui/mall/*.gd,scenes/ui/mall/*.tscn,assets/characters/*.png,assets/fonts/fusion-pixel-12px/*.woff2,assets/ui/*.tres,assets/ui/auth/generated/*.png,assets/ui/datawhale_honor/*.png" exclude_filter="assets/maps/square/**/*.png,assets/maps/work_zone/**/*.png,assets/maps/personal_space/**/*.png" export_path="build/packs/cafe_interior.pck" patches=PackedStringArray() @@ -168,7 +168,7 @@ dedicated_server=false custom_features="" export_filter="scenes" export_files=PackedStringArray("res://scenes/Maps/personal_space.tscn") -include_filter="scenes/Maps/*.gd,scenes/characters/*.gd,scenes/characters/*.tscn,scenes/prefabs/items/*.gd,scenes/prefabs/items/*.tscn,scenes/prefabs/ui/*.gd,scenes/prefabs/ui/*.tscn,scenes/ui/*.gd,scenes/ui/*.tscn,scenes/ui/mall/*.gd,scenes/ui/mall/*.tscn,assets/characters/*.png,assets/ui/*.tres,assets/ui/auth/generated/*.png,assets/ui/datawhale_honor/*.png,assets/maps/personal_space/v1/base/personal_room_25d_sidewalls_wider_not_longer_v1.png,assets/maps/personal_space/v1/decor/*.png" +include_filter="scenes/Maps/*.gd,scenes/characters/*.gd,scenes/characters/*.tscn,scenes/prefabs/items/*.gd,scenes/prefabs/items/*.tscn,scenes/prefabs/ui/*.gd,scenes/prefabs/ui/*.tscn,scenes/ui/*.gd,scenes/ui/*.tscn,scenes/ui/mall/*.gd,scenes/ui/mall/*.tscn,assets/characters/*.png,assets/fonts/fusion-pixel-12px/*.woff2,assets/ui/*.tres,assets/ui/auth/generated/*.png,assets/ui/datawhale_honor/*.png,assets/maps/personal_space/v1/base/personal_room_25d_sidewalls_wider_not_longer_v1.png,assets/maps/personal_space/v1/decor/*.png" exclude_filter="assets/maps/square/**/*.png,assets/maps/work_zone/**/*.png,assets/maps/cafe/**/*.png" export_path="build/packs/personal_space.pck" patches=PackedStringArray() @@ -213,3 +213,78 @@ variant/extensions_support=false variant/thread_support=false vram_texture_compression/for_desktop=true vram_texture_compression/for_mobile=false + +[preset.7] + +name="WhaleTown macOS" +platform="macOS" +runnable=true +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="build/**,output/**,docs/**,scripts/**,tools/**,assets/characters/reference/**,assets/characters/skin_generation_references/**,assets/ui/chat/**,assets/ui/auth/registration_choice/generated/**,assets/ui/auth/registration_choice/preview/**,assets/ui/auth/registration_choice/redesign_preview/**,assets/ui/auth/registration_choice/redesign/*_magenta.png*,assets/maps/square/v1/square_base_terrain_v*.png*,assets/maps/work_zone/v1/**,assets/maps/work_zone/v2/**,assets/maps/work_zone/v3/**,assets/maps/work_zone/v4/**,assets/maps/work_zone/v5/**,assets/maps/work_zone/v6/**,assets/maps/work_zone/v7/**,assets/maps/work_zone/v8/**,assets/maps/work_zone/v9/**,assets/maps/work_zone/v10/**,assets/maps/work_zone/v11/**,assets/maps/work_zone/v12/**,assets/maps/work_zone/v16/**,assets/maps/work_zone/v17/**,assets/maps/work_zone/v18/**,assets/maps/work_zone/v19/**,assets/maps/work_zone/v20/**,assets/maps/work_zone/v21/**,assets/maps/work_zone/v22/**,assets/maps/work_zone/v23/**,assets/maps/work_zone/v24/**,assets/maps/work_zone/v25/**" +export_path="build/desktop/macos/WhaleTown.app" +patches=PackedStringArray() +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.7.options] + +export/distribution_type=0 +binary_format/architecture="universal" +custom_template/debug="" +custom_template/release="" +debug/export_console_wrapper=1 +application/short_version="1.0" +application/version="1.0.0" +application/icon="res://icon.svg" +application/bundle_identifier="com.whaletown.game" +application/signature="" +application/app_category="Games" +display/high_res=true +codesign/codesign=1 +codesign/installer_identity="" +codesign/apple_team_id="" +notarization/notarization=0 + +[preset.8] + +name="WhaleTown Windows" +platform="Windows Desktop" +runnable=false +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="build/**,output/**,docs/**,scripts/**,tools/**,assets/characters/reference/**,assets/characters/skin_generation_references/**,assets/ui/chat/**,assets/ui/auth/registration_choice/generated/**,assets/ui/auth/registration_choice/preview/**,assets/ui/auth/registration_choice/redesign_preview/**,assets/ui/auth/registration_choice/redesign/*_magenta.png*,assets/maps/square/v1/square_base_terrain_v*.png*,assets/maps/work_zone/v1/**,assets/maps/work_zone/v2/**,assets/maps/work_zone/v3/**,assets/maps/work_zone/v4/**,assets/maps/work_zone/v5/**,assets/maps/work_zone/v6/**,assets/maps/work_zone/v7/**,assets/maps/work_zone/v8/**,assets/maps/work_zone/v9/**,assets/maps/work_zone/v10/**,assets/maps/work_zone/v11/**,assets/maps/work_zone/v12/**,assets/maps/work_zone/v16/**,assets/maps/work_zone/v17/**,assets/maps/work_zone/v18/**,assets/maps/work_zone/v19/**,assets/maps/work_zone/v20/**,assets/maps/work_zone/v21/**,assets/maps/work_zone/v22/**,assets/maps/work_zone/v23/**,assets/maps/work_zone/v24/**,assets/maps/work_zone/v25/**" +export_path="build/desktop/windows/WhaleTown.exe" +patches=PackedStringArray() +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.8.options] + +binary_format/architecture="x86_64" +custom_template/debug="" +custom_template/release="" +debug/export_console_wrapper=1 +binary_format/embed_pck=true +texture_format/bptc=true +texture_format/s3tc=true +texture_format/etc2=false +application/modify_resources=true +application/icon="res://icon.svg" +application/file_version="1.0.0.0" +application/product_version="1.0.0" +application/product_name="WhaleTown" diff --git a/project.godot b/project.godot index f07653c..de49dbe 100644 --- a/project.godot +++ b/project.godot @@ -14,7 +14,8 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true [application] -config/name="WhaleTown V2" +config/name="WhaleTown" +config/version="1.0.0" run/main_scene="res://scenes/ui/WebBootstrap.tscn" config/features=PackedStringArray("4.6", "GL Compatibility") config/icon="res://icon.svg" @@ -33,6 +34,7 @@ CafeCompanionManager="*res://_Core/managers/CafeCompanionManager.gd" AppearanceManager="*res://_Core/managers/AppearanceManager.gd" SettingsManager="*res://_Core/managers/SettingsManager.gd" NotificationSoundManager="*res://_Core/managers/NotificationSoundManager.gd" +InputFocusManager="*res://_Core/managers/InputFocusManager.gd" [display] diff --git a/scenes/Maps/CafeInterior.gd b/scenes/Maps/CafeInterior.gd index db11e34..352f4c7 100644 --- a/scenes/Maps/CafeInterior.gd +++ b/scenes/Maps/CafeInterior.gd @@ -16,11 +16,12 @@ const WORK_ZONE_CAFE_RETURN_POSITION: Vector2 = Vector2(-1093, 560) const CAFE_DOOR_POSITION: Vector2 = Vector2(0, 392) const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres") const COMPANION_NAMEPLATE_RENDER_SCALE: float = 0.5 -const COMPANION_NAMEPLATE_FONT_SIZE: int = 24 -const COMPANION_NAMEPLATE_VISUAL_HEIGHT: int = 22 -const COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH: int = 76 -const COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH: int = 118 -const COMPANION_NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12 +const COMPANION_NAMEPLATE_FONT_SIZE: int = 16 +const COMPANION_NAMEPLATE_VISUAL_HEIGHT: int = 15 +const COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH: int = 64 +const COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH: int = 100 +const COMPANION_NAMEPLATE_FONT_ZH: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2") +const COMPANION_NAMEPLATE_FONT_LATIN: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2") const NPC_NAMEPLATE_OFFSET_Y: float = -136.0 const HIRED_PLAYER_NAMEPLATE_OFFSET_Y: float = -96.0 @@ -36,6 +37,7 @@ const HIRED_PLAYER_NAMEPLATE_OFFSET_Y: float = -96.0 var _isChangingScene: bool = false var _lastRecruitmentClickMsec: int = 0 +var _companionNicknameFont: FontFile func _ready() -> void: _align_service_occupants() @@ -252,34 +254,20 @@ func _configure_companion_nameplate(label: Label, personaName: String, offsetY: label.clip_text = true label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS label.mouse_filter = Control.MOUSE_FILTER_IGNORE - label.add_theme_color_override("font_color", Color(0.12, 0.20, 0.24, 1.0)) - label.add_theme_color_override("font_shadow_color", Color(1.0, 1.0, 1.0, 0.85)) - label.add_theme_constant_override("shadow_offset_x", 0) - label.add_theme_constant_override("shadow_offset_y", 1) + label.add_theme_color_override("font_color", Color(0.09, 0.25, 0.31, 1.0)) + label.add_theme_color_override("font_outline_color", Color(1.0, 0.965, 0.88, 0.98)) + label.add_theme_constant_override("outline_size", 3) + label.add_theme_font_override("font", _get_companion_nickname_font()) label.add_theme_font_size_override("font_size", COMPANION_NAMEPLATE_FONT_SIZE) - label.add_theme_stylebox_override("normal", _create_companion_nameplate_style()) + label.add_theme_stylebox_override("normal", StyleBoxEmpty.new()) func _companion_nameplate_visual_width(displayName: String) -> int: - var estimatedWidth := displayName.length() * COMPANION_NAMEPLATE_VISUAL_CHAR_WIDTH + 28 - return clampi(estimatedWidth, COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH, COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH) + var measuredWidth := _get_companion_nickname_font().get_string_size(displayName, HORIZONTAL_ALIGNMENT_LEFT, -1, COMPANION_NAMEPLATE_FONT_SIZE).x + var visualWidth := ceili(measuredWidth * COMPANION_NAMEPLATE_RENDER_SCALE + 20.0) + return clampi(visualWidth, COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH, COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH) -func _create_companion_nameplate_style() -> StyleBoxFlat: - var style := StyleBoxFlat.new() - style.bg_color = Color(1.0, 0.988, 0.955, 0.96) - style.border_color = Color(0.18, 0.34, 0.38, 0.92) - style.border_width_left = 2 - style.border_width_top = 2 - style.border_width_right = 2 - style.border_width_bottom = 2 - style.corner_radius_top_left = 12 - style.corner_radius_top_right = 12 - style.corner_radius_bottom_left = 12 - style.corner_radius_bottom_right = 12 - style.content_margin_left = 12 - style.content_margin_top = 4 - style.content_margin_right = 12 - style.content_margin_bottom = 4 - style.shadow_color = Color(0.05, 0.08, 0.09, 0.18) - style.shadow_size = 4 - style.shadow_offset = Vector2(0, 2) - return style +func _get_companion_nickname_font() -> FontFile: + if _companionNicknameFont == null: + _companionNicknameFont = COMPANION_NAMEPLATE_FONT_ZH.duplicate() as FontFile + _companionNicknameFont.fallbacks = [COMPANION_NAMEPLATE_FONT_LATIN] + return _companionNicknameFont diff --git a/scenes/Maps/MapMultiplayerController.gd b/scenes/Maps/MapMultiplayerController.gd index 804f837..880133a 100644 --- a/scenes/Maps/MapMultiplayerController.gd +++ b/scenes/Maps/MapMultiplayerController.gd @@ -7,6 +7,7 @@ extends Node # ============================================================================ const REMOTE_PLAYER_SCENE: PackedScene = preload("res://scenes/characters/remote_player.tscn") +const NETWORK_NPC_SCENE: PackedScene = preload("res://scenes/characters/network_npc.tscn") const CHAT_BUBBLE_SCENE: PackedScene = preload("res://scenes/ui/ChatBubble.tscn") const DEFAULT_MAP_ID: String = "whale_port" const POSITION_SEND_INTERVAL: float = 0.12 @@ -19,19 +20,26 @@ const PRIVATE_CHAT_FORWARD_DOT_THRESHOLD: float = 0.25 @export var map_id: String = DEFAULT_MAP_ID @export var local_player_path: NodePath = NodePath("../Characters/Players/Player") @export var remote_players_root_path: NodePath = NodePath("../Characters/Players/RemotePlayers") +@export var network_npcs_root_path: NodePath = NodePath("../Characters/Npcs") var _local_player: Node2D var _remote_players_root: Node2D +var _network_npcs_root: Node2D var _remote_players: Dictionary = {} +var _network_npcs: Dictionary = {} var _pending_remote_players: Dictionary = {} var _last_sent_position: Vector2 = Vector2.INF var _last_sent_at_msec: int = 0 +var _last_sent_direction: String = "" +var _last_sent_movement_state: String = "" +var _movement_sequence: int = 0 var _last_private_chat_interaction_msec: int = 0 var _last_friend_request_interaction_msec: int = 0 func _ready() -> void: _local_player = get_node_or_null(local_player_path) as Node2D _remote_players_root = get_node_or_null(remote_players_root_path) as Node2D + _network_npcs_root = get_node_or_null(network_npcs_root_path) as Node2D if _local_player == null: push_warning("MapMultiplayerController: local player not found.") @@ -46,6 +54,9 @@ func _ready() -> void: eventSystem.call("connect_event", EventNames.PLAYER_MOVED, _on_local_player_moved, self) eventSystem.call("connect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_chat_login_success, self) eventSystem.call("connect_event", EventNames.REMOTE_PLAYERS_SNAPSHOT_READY, _on_remote_players_snapshot_ready, self) + eventSystem.call("connect_event", EventNames.NPC_SNAPSHOT_READY, _on_npc_snapshot_ready, self) + eventSystem.call("connect_event", EventNames.NPC_ACTION_STARTED, _on_npc_action_started, self) + eventSystem.call("connect_event", EventNames.NPC_ACTION_COMPLETED, _on_npc_action_completed, self) eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_POSITION_UPDATED, _on_remote_player_position_updated, self) eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_JOINED, _on_remote_player_joined, self) eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_LEFT, _on_remote_player_left, self) @@ -78,6 +89,9 @@ func _exit_tree() -> void: eventSystem.call("disconnect_event", EventNames.PLAYER_MOVED, _on_local_player_moved, self) eventSystem.call("disconnect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_chat_login_success, self) eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYERS_SNAPSHOT_READY, _on_remote_players_snapshot_ready, self) + eventSystem.call("disconnect_event", EventNames.NPC_SNAPSHOT_READY, _on_npc_snapshot_ready, self) + eventSystem.call("disconnect_event", EventNames.NPC_ACTION_STARTED, _on_npc_action_started, self) + eventSystem.call("disconnect_event", EventNames.NPC_ACTION_COMPLETED, _on_npc_action_completed, self) eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_POSITION_UPDATED, _on_remote_player_position_updated, self) eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_JOINED, _on_remote_player_joined, self) eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_LEFT, _on_remote_player_left, self) @@ -175,7 +189,9 @@ func _on_chat_login_success(_data: Dictionary) -> void: func _on_local_player_moved(data: Dictionary) -> void: var position_variant: Variant = data.get("position", null) if position_variant is Vector2: - _send_position(position_variant as Vector2) + var direction := str(data.get("direction", _get_local_direction())) + var movementState := str(data.get("movement_state", "walk")) + _send_position(position_variant as Vector2, direction, movementState) func _send_world_ready() -> void: if _local_player == null: @@ -183,13 +199,16 @@ func _send_world_ready() -> void: var chatManager := _get_chat_manager() if chatManager == null or not chatManager.has_method("mark_world_ready"): return - chatManager.call("mark_world_ready", _get_map_id(), _local_player.global_position) + chatManager.call("mark_world_ready", _get_map_id(), _local_player.global_position, _get_local_direction(), "idle", _movement_sequence) -func _send_position(position: Vector2, force: bool = false) -> void: +func _send_position(position: Vector2, direction: String, movementState: String, force: bool = false) -> void: + var normalizedDirection := _normalize_direction(direction) + var normalizedMovementState := _normalize_movement_state(movementState) + var stateChanged := normalizedDirection != _last_sent_direction or normalizedMovementState != _last_sent_movement_state var now := Time.get_ticks_msec() - if not force and now - _last_sent_at_msec < int(POSITION_SEND_INTERVAL * 1000.0): + if not force and not stateChanged and now - _last_sent_at_msec < int(POSITION_SEND_INTERVAL * 1000.0): return - if not force and _last_sent_position != Vector2.INF and _last_sent_position.distance_to(position) < MIN_POSITION_DELTA: + if not force and not stateChanged and _last_sent_position != Vector2.INF and _last_sent_position.distance_to(position) < MIN_POSITION_DELTA: return var chatManager := _get_chat_manager() @@ -200,7 +219,10 @@ func _send_position(position: Vector2, force: bool = false) -> void: _last_sent_position = position _last_sent_at_msec = now - chatManager.call("update_player_position", position.x, position.y, _get_map_id()) + _last_sent_direction = normalizedDirection + _last_sent_movement_state = normalizedMovementState + _movement_sequence += 1 + chatManager.call("update_player_position", position.x, position.y, _get_map_id(), normalizedDirection, normalizedMovementState, _movement_sequence) func _on_remote_player_joined(data: Dictionary) -> void: if not _is_event_for_current_map(data): @@ -210,8 +232,8 @@ func _on_remote_player_joined(data: Dictionary) -> void: return var remote_player := _ensure_remote_player(user_id, data) var position_variant: Variant = data.get("position", null) - if remote_player != null and position_variant is Vector2 and remote_player.has_method("update_position"): - remote_player.call("update_position", position_variant as Vector2) + if remote_player != null and position_variant is Vector2: + _update_remote_player_position(remote_player, position_variant as Vector2, data) func _on_remote_players_snapshot_ready(data: Dictionary) -> void: if not _is_event_for_current_map(data): @@ -230,8 +252,8 @@ func _on_remote_players_snapshot_ready(data: Dictionary) -> void: seen_user_ids[user_id] = true var remote_player := _ensure_remote_player(user_id, player_data) var position_variant: Variant = player_data.get("position", null) - if remote_player != null and position_variant is Vector2 and remote_player.has_method("update_position"): - remote_player.call("update_position", position_variant as Vector2) + if remote_player != null and position_variant is Vector2: + _update_remote_player_position(remote_player, position_variant as Vector2, player_data) for user_id_variant in _remote_players.keys().duplicate(): var user_id := str(user_id_variant) @@ -246,6 +268,70 @@ func _on_remote_players_snapshot_ready(data: Dictionary) -> void: if not seen_user_ids.has(pendingUserId): _pending_remote_players.erase(pendingUserId) +func _on_npc_snapshot_ready(data: Dictionary) -> void: + if not _is_event_for_current_map(data): + return + if _network_npcs_root == null: + return + + var seenNpcIds: Dictionary = {} + var npcsVariant: Variant = data.get("npcs", []) + if npcsVariant is Array: + for npcVariant in npcsVariant: + if not (npcVariant is Dictionary): + continue + var npcData: Dictionary = npcVariant + var npcId := str(npcData.get("npc_id", npcData.get("npcId", ""))).strip_edges() + if npcId.is_empty(): + continue + seenNpcIds[npcId] = true + var networkNpc := _ensure_network_npc(npcId) + if networkNpc != null and networkNpc.has_method("apply_snapshot"): + networkNpc.call("apply_snapshot", npcData) + + for npcIdVariant in _network_npcs.keys().duplicate(): + var npcId := str(npcIdVariant) + if seenNpcIds.has(npcId): + continue + var networkNpc := _network_npcs.get(npcId) as Node + _network_npcs.erase(npcId) + if is_instance_valid(networkNpc): + networkNpc.queue_free() + +func _ensure_network_npc(npcId: String) -> Node2D: + if _network_npcs.has(npcId): + var existingNpc := _network_npcs.get(npcId) as Node2D + if is_instance_valid(existingNpc): + return existingNpc + _network_npcs.erase(npcId) + + var networkNpc := NETWORK_NPC_SCENE.instantiate() as Node2D + if networkNpc == null: + return null + networkNpc.name = "NetworkNpc_%s" % npcId + networkNpc.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + _network_npcs_root.add_child(networkNpc) + _network_npcs[npcId] = networkNpc + return networkNpc + +func _on_npc_action_started(data: Dictionary) -> void: + _apply_npc_action(data, "apply_action_started") + +func _on_npc_action_completed(data: Dictionary) -> void: + _apply_npc_action(data, "apply_action_completed") + +func _apply_npc_action(data: Dictionary, methodName: String) -> void: + if not _is_event_for_current_map(data): + return + var npcId := str(data.get("npc_id", data.get("npcId", ""))).strip_edges() + if npcId.is_empty(): + return + var networkNpc := _network_npcs.get(npcId) as Node + if networkNpc == null: + networkNpc = _ensure_network_npc(npcId) + if networkNpc != null and networkNpc.has_method(methodName): + networkNpc.call(methodName, data) + func _on_remote_player_position_updated(data: Dictionary) -> void: if not _is_event_for_current_map(data): return @@ -260,8 +346,8 @@ func _on_remote_player_position_updated(data: Dictionary) -> void: return var remote_player := _ensure_remote_player(user_id, data) - if remote_player != null and remote_player.has_method("update_position"): - remote_player.call("update_position", position_variant as Vector2) + if remote_player != null: + _update_remote_player_position(remote_player, position_variant as Vector2, data) func _on_remote_player_left(data: Dictionary) -> void: if not _is_event_for_current_map(data): @@ -357,8 +443,8 @@ func _on_remote_skin_ready(data: Dictionary) -> void: _pending_remote_players.erase(userId) var remotePlayer := _ensure_remote_player(userId, pendingData) var positionVariant: Variant = pendingData.get("position", null) - if remotePlayer != null and positionVariant is Vector2 and remotePlayer.has_method("update_position"): - remotePlayer.call("update_position", positionVariant as Vector2) + if remotePlayer != null and positionVariant is Vector2: + _update_remote_player_position(remotePlayer, positionVariant as Vector2, pendingData) func _on_remote_skin_failed(data: Dictionary) -> void: var skinId := str(data.get("skin_id", "")).strip_edges() @@ -372,14 +458,14 @@ func _on_remote_skin_failed(data: Dictionary) -> void: continue _pending_remote_players.erase(userId) var fallbackData := pendingData.duplicate(true) - fallbackData["skin_id"] = "" - fallbackData["skinId"] = "" + fallbackData["skin_id"] = "classic_whale" + fallbackData["skinId"] = "classic_whale" fallbackData.erase("skin_asset") fallbackData.erase("skinAsset") var remotePlayer := _ensure_remote_player(userId, fallbackData) var positionVariant: Variant = pendingData.get("position", null) - if remotePlayer != null and positionVariant is Vector2 and remotePlayer.has_method("update_position"): - remotePlayer.call("update_position", positionVariant as Vector2) + if remotePlayer != null and positionVariant is Vector2: + _update_remote_player_position(remotePlayer, positionVariant as Vector2, pendingData) func _find_private_chat_target() -> Node2D: if _local_player == null or _remote_players.is_empty(): @@ -436,9 +522,31 @@ func _apply_remote_player_metadata(remote_player: Node2D, data: Dictionary) -> v var username := str(data.get("username", "")).strip_edges() if not username.is_empty(): remote_player.set("username", username) - if data.has("skin_id") or data.has("skinId") or data.has("skin_asset") or data.has("skinAsset") or data.has("avatar_id") or data.has("avatarId") or data.has("cafe_companion") or data.has("cafeCompanion"): - if remote_player.has_method("setup"): - remote_player.call("setup", data) + if remote_player.has_method("update_metadata"): + remote_player.call("update_metadata", data) + +func _update_remote_player_position(remotePlayer: Node2D, position: Vector2, data: Dictionary) -> void: + if not remotePlayer.has_method("update_position"): + return + remotePlayer.call( + "update_position", + position, + str(data.get("direction", "")), + str(data.get("movement_state", data.get("movementState", "walk"))), + int(data.get("sequence", -1)) + ) + +func _get_local_direction() -> String: + if _local_player != null: + return _normalize_direction(str(_local_player.get("lastDirection"))) + return "down" + +func _normalize_direction(value: String) -> String: + var normalized := value.strip_edges().to_lower() + return normalized if normalized in ["down", "up", "right", "left"] else "down" + +func _normalize_movement_state(value: String) -> String: + return "walk" if value.strip_edges().to_lower() == "walk" else "idle" func _resolve_chat_bubble_target(data: Dictionary) -> Node2D: var from_user := str(data.get("from_user", data.get("username", ""))).strip_edges() diff --git a/scenes/Maps/Square.gd b/scenes/Maps/Square.gd index 2cde95c..5974351 100644 --- a/scenes/Maps/Square.gd +++ b/scenes/Maps/Square.gd @@ -20,6 +20,9 @@ const CAMERA_LIMIT_BOTTOM: int = 960 func _ready() -> void: _apply_spawn_point() _configure_camera() + var chatManager := get_node_or_null("/root/ChatManager") + if chatManager != null and chatManager.has_method("is_guest_mode") and bool(chatManager.call("is_guest_mode")): + player.set_spectator_mode(true) func _apply_spawn_point() -> void: var spawnName: String = SceneManager.get_next_spawn_name() diff --git a/scenes/Maps/cafe_interior.tscn b/scenes/Maps/cafe_interior.tscn index d258da3..85ac0cd 100644 --- a/scenes/Maps/cafe_interior.tscn +++ b/scenes/Maps/cafe_interior.tscn @@ -146,6 +146,9 @@ disabled = true [node name="RemotePlayers" type="Node2D" parent="YSortWorld/Characters/Players" unique_id=2071839484] y_sort_enabled = true +[node name="Npcs" type="Node2D" parent="YSortWorld/Characters"] +y_sort_enabled = true + [node name="CafeWhaleBaristaNpc" parent="YSortWorld/Characters" unique_id=1188569491 instance=ExtResource("8_barista_npc")] position = Vector2(-472, -291) diff --git a/scenes/Maps/square.tscn b/scenes/Maps/square.tscn index 664f429..0d4ab0e 100644 --- a/scenes/Maps/square.tscn +++ b/scenes/Maps/square.tscn @@ -12,9 +12,7 @@ [ext_resource type="Texture2D" uid="uid://c8ubnxut51f0r" path="res://assets/maps/square/v1/props/bottom_entrance_left_task_props_v2_hd_clean.png" id="12_lefttask"] [ext_resource type="Texture2D" uid="uid://b4bvp0r5hua1k" path="res://assets/maps/square/v1/props/bottom_entrance_right_service_props_v2_hd_clean.png" id="13_rightsvc"] [ext_resource type="PackedScene" path="res://scenes/characters/player.tscn" id="14_u1t8b"] -[ext_resource type="PackedScene" path="res://scenes/characters/npc.tscn" id="17_qog0y"] [ext_resource type="Texture2D" uid="uid://c0kgmsaach8wh" path="res://assets/maps/square/v1/props/foliage_v13_concept_broadleaf_single/broadleaf_tree_v13_clean.png" id="19_edt5w"] -[ext_resource type="PackedScene" path="res://scenes/characters/crayfish_npc.tscn" id="20_lemld"] [ext_resource type="Texture2D" uid="uid://q3vldth6fyvw" path="res://assets/maps/square/v1/props/foliage_v14_concept_evergreen_single/evergreen_tree_v14_clean.png" id="20_rixdf"] [ext_resource type="Texture2D" uid="uid://cbqj51mpelsr4" path="res://assets/maps/square/v1/props/top_fence_v1/top_fence_v1_clean.png" id="22_jagxe"] [ext_resource type="Texture2D" uid="uid://csxvgs1v5puvt" path="res://assets/maps/square/v1/props/guild_left_tree_flowers_v1/guild_left_tree_flowers_v1_clean.png" id="24_8hf0h"] @@ -35,6 +33,7 @@ [ext_resource type="Script" uid="uid://dxupavbgcw3tw" path="res://scenes/Maps/MapMultiplayerController.gd" id="38_multiplayer"] [ext_resource type="PackedScene" path="res://scenes/ui/PlayerHud.tscn" id="39_playerhud"] [ext_resource type="PackedScene" path="res://scenes/ui/FriendListPanel.tscn" id="40_friendpanel"] +[ext_resource type="PackedScene" path="res://scenes/ui/WorldBulletinPanel.tscn" id="45_world_bulletin"] [ext_resource type="PackedScene" path="res://scenes/ui/SettingsPanel.tscn" id="41_settingspanel"] [ext_resource type="Script" uid="uid://dr6uk7m4fsr4l" path="res://scenes/Maps/ScenePortal.gd" id="42_sceneportal"] [ext_resource type="Script" uid="uid://biu0igl6133q5" path="res://scenes/Maps/Square.gd" id="43_square"] @@ -134,6 +133,9 @@ layer = 10 [node name="FriendListPanel" parent="UILayer" unique_id=914934559 instance=ExtResource("40_friendpanel")] +[node name="WorldBulletinPanel" parent="UILayer" instance=ExtResource("45_world_bulletin")] +z_index = 1000 + [node name="SettingsPanel" parent="UILayer" unique_id=588068795 instance=ExtResource("41_settingspanel")] [node name="HDGrassBase" type="Sprite2D" parent="." unique_id=1853750890] @@ -385,20 +387,6 @@ y_sort_enabled = true [node name="Npcs" type="Node2D" parent="YSortWorld/Characters" unique_id=1114010880] y_sort_enabled = true -[node name="GuildReceptionNpc" parent="YSortWorld/Characters/Npcs" unique_id=442345899 instance=ExtResource("17_qog0y")] -texture_filter = 1 -position = Vector2(-199, -515) -npcName = "范鲸晶" -dialogue = "欢迎来到 WhaleTown 广场。上方是公会大厅,南边是小镇入口,左边通往码头。" -showNameplate = true -nameplateOffsetY = -72.0 - -[node name="DockGuideNpc" parent="YSortWorld/Characters/Npcs" unique_id=626857126 instance=ExtResource("20_lemld")] -texture_filter = 1 -position = Vector2(-825, 437) -showNameplate = true -nameplateOffsetY = -66.0 - [node name="MultiplayerController" type="Node" parent="YSortWorld" unique_id=1810372571] script = ExtResource("38_multiplayer") diff --git a/scenes/Maps/work_zone.tscn b/scenes/Maps/work_zone.tscn index 6741b32..3e5fc7a 100644 --- a/scenes/Maps/work_zone.tscn +++ b/scenes/Maps/work_zone.tscn @@ -241,6 +241,9 @@ position = Vector2(0, 921) y_sort_enabled = true position = Vector2(0, -1) +[node name="Npcs" type="Node2D" parent="YSortWorld/Characters"] +y_sort_enabled = true + [node name="MultiplayerController" type="Node" parent="YSortWorld" unique_id=1024843959] script = ExtResource("26_multiplayer") map_id = "work_zone" diff --git a/scenes/characters/NPCController.gd b/scenes/characters/NPCController.gd index 2a0142d..6ce1834 100644 --- a/scenes/characters/NPCController.gd +++ b/scenes/characters/NPCController.gd @@ -8,27 +8,27 @@ class_name NPCController # 主要功能: # - 播放 NPC 待机动画 # - 响应玩家射线交互 -# - 触发聊天气泡与 NPC 对话事件 +# - 打开 NPC 对话框并广播 NPC 对话事件 # -# 依赖: EventSystem, EventNames, ChatBubble +# 依赖: EventSystem, EventNames, ChatUI # 作者: Codex # 创建时间: 2026-03-10 # ============================================================================ signal interaction_happened(text: String) -const CHAT_BUBBLE_SCENE: PackedScene = preload("res://scenes/ui/ChatBubble.tscn") -const CHAT_BUBBLE_LAYER_NAME: String = "WorldChatBubbleLayer" -const CHAT_BUBBLE_TARGET_OFFSET: Vector2 = Vector2(0, -84) const NPC_TALKED_EVENT: String = "npc_talked" +const NPC_COLLISION_LAYER: int = 2 +const NPC_COLLISION_MASK: int = 1 const WORLD_SORT_Z_OFFSET: int = 2048 const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres") const NAMEPLATE_RENDER_SCALE: float = 0.5 -const NAMEPLATE_FONT_SIZE: int = 24 -const NAMEPLATE_VISUAL_HEIGHT: int = 22 -const NAMEPLATE_VISUAL_MIN_WIDTH: int = 76 -const NAMEPLATE_VISUAL_MAX_WIDTH: int = 118 -const NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12 +const NAMEPLATE_FONT_SIZE: int = 16 +const NAMEPLATE_VISUAL_HEIGHT: int = 15 +const NAMEPLATE_VISUAL_MIN_WIDTH: int = 64 +const NAMEPLATE_VISUAL_MAX_WIDTH: int = 100 +const NAMEPLATE_FONT_ZH: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2") +const NAMEPLATE_FONT_LATIN: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2") @export var npcName: String = "NPC" @export_multiline var dialogue: String = "欢迎来到WhaleTown,我是镇长范鲸晶" @@ -38,6 +38,7 @@ const NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12 @onready var animation_player: AnimationPlayer = $AnimationPlayer var _nameplate: Label +var _nicknameFont: FontFile func _ready() -> void: # 播放场景里配置好的待机动画,让不同 NPC 可以复用同一个控制器。 @@ -46,16 +47,18 @@ func _ready() -> void: _update_nameplate() _update_world_sort_z() - # 保持 NPC 可被玩家射线与角色碰撞识别。 - collision_layer = 3 - collision_mask = 3 + # NPC 单独占用交互层;玩家的物理掩码同时包含地图层和 NPC 层。 + collision_layer = NPC_COLLISION_LAYER + collision_mask = NPC_COLLISION_MASK func _physics_process(_delta: float) -> void: _update_world_sort_z() -# 处理玩家交互,展示气泡并向全局事件系统广播。 +# 处理玩家交互,打开统一对话框并向全局事件系统广播。 func interact() -> void: - show_bubble(dialogue) + var chatUi := get_tree().root.find_child("ChatUI", true, false) + if chatUi != null and chatUi.has_method("show_npc_dialogue"): + chatUi.call("show_npc_dialogue", npcName, dialogue) var eventSystem: Node = get_node_or_null("/root/EventSystem") if eventSystem != null: eventSystem.call("emit_event", NPC_TALKED_EVENT, { @@ -65,31 +68,6 @@ func interact() -> void: }) interaction_happened.emit(dialogue) -# 在 NPC 头顶生成一次性聊天气泡。 -# -# 参数: -# text: String - 要展示的对话内容 -func show_bubble(text: String) -> void: - var bubble: Control = CHAT_BUBBLE_SCENE.instantiate() as Control - if bubble == null: - return - var bubbleLayer: CanvasLayer = _get_chat_bubble_layer() - bubbleLayer.add_child(bubble) - if bubble.has_method("set_text"): - bubble.call("set_text", text, self, CHAT_BUBBLE_TARGET_OFFSET) - -func _get_chat_bubble_layer() -> CanvasLayer: - var root: Window = get_tree().root - var layer: CanvasLayer = root.get_node_or_null(CHAT_BUBBLE_LAYER_NAME) as CanvasLayer - if layer != null: - return layer - - layer = CanvasLayer.new() - layer.name = CHAT_BUBBLE_LAYER_NAME - layer.layer = 20 - root.add_child(layer) - return layer - func _update_world_sort_z() -> void: z_index = WORLD_SORT_Z_OFFSET + int(round(global_position.y)) @@ -123,34 +101,20 @@ func _update_nameplate() -> void: _nameplate.clip_text = true _nameplate.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS _nameplate.mouse_filter = Control.MOUSE_FILTER_IGNORE - _nameplate.add_theme_color_override("font_color", Color(0.12, 0.20, 0.24, 1.0)) - _nameplate.add_theme_color_override("font_shadow_color", Color(1.0, 1.0, 1.0, 0.85)) - _nameplate.add_theme_constant_override("shadow_offset_x", 0) - _nameplate.add_theme_constant_override("shadow_offset_y", 1) + _nameplate.add_theme_color_override("font_color", Color(0.09, 0.25, 0.31, 1.0)) + _nameplate.add_theme_color_override("font_outline_color", Color(1.0, 0.965, 0.88, 0.98)) + _nameplate.add_theme_constant_override("outline_size", 3) + _nameplate.add_theme_font_override("font", _get_nickname_font()) _nameplate.add_theme_font_size_override("font_size", NAMEPLATE_FONT_SIZE) - _nameplate.add_theme_stylebox_override("normal", _create_nameplate_style()) + _nameplate.add_theme_stylebox_override("normal", StyleBoxEmpty.new()) func _nameplate_visual_width(displayName: String) -> int: - var estimatedWidth := displayName.length() * NAMEPLATE_VISUAL_CHAR_WIDTH + 28 - return clampi(estimatedWidth, NAMEPLATE_VISUAL_MIN_WIDTH, NAMEPLATE_VISUAL_MAX_WIDTH) + var measuredWidth := _get_nickname_font().get_string_size(displayName, HORIZONTAL_ALIGNMENT_LEFT, -1, NAMEPLATE_FONT_SIZE).x + var visualWidth := ceili(measuredWidth * NAMEPLATE_RENDER_SCALE + 20.0) + return clampi(visualWidth, NAMEPLATE_VISUAL_MIN_WIDTH, NAMEPLATE_VISUAL_MAX_WIDTH) -func _create_nameplate_style() -> StyleBoxFlat: - var style := StyleBoxFlat.new() - style.bg_color = Color(1.0, 0.988, 0.955, 0.96) - style.border_color = Color(0.18, 0.34, 0.38, 0.92) - style.border_width_left = 2 - style.border_width_top = 2 - style.border_width_right = 2 - style.border_width_bottom = 2 - style.corner_radius_top_left = 12 - style.corner_radius_top_right = 12 - style.corner_radius_bottom_left = 12 - style.corner_radius_bottom_right = 12 - style.content_margin_left = 12 - style.content_margin_top = 4 - style.content_margin_right = 12 - style.content_margin_bottom = 4 - style.shadow_color = Color(0.05, 0.08, 0.09, 0.18) - style.shadow_size = 4 - style.shadow_offset = Vector2(0, 2) - return style +func _get_nickname_font() -> FontFile: + if _nicknameFont == null: + _nicknameFont = NAMEPLATE_FONT_ZH.duplicate() as FontFile + _nicknameFont.fallbacks = [NAMEPLATE_FONT_LATIN] + return _nicknameFont diff --git a/scenes/characters/NetworkNpc.gd b/scenes/characters/NetworkNpc.gd new file mode 100644 index 0000000..8f076c5 --- /dev/null +++ b/scenes/characters/NetworkNpc.gd @@ -0,0 +1,510 @@ +extends NPCController +class_name NetworkNpc + +var npcId: String = "" +var stateVersion: int = -1 +var worldState: String = "idle" +var publicIntention: String = "" +var dailyGoal: String = "" +var planSource: String = "fallback" +var currentActivity: Dictionary = {} +var movementState: String = "idle" +var activeAction: Dictionary = {} +var _actionStartedLocalMsec: int = 0 +var _actionCompletesLocalMsec: int = 0 +const DIRECTION_ROWS: Dictionary = {"down": 0, "up": 1, "right": 2, "left": 3} +const WALK_ANIMATION_LENGTH: float = 0.8 +const IDLE_ANIMATION_LENGTH: float = 1.2 +const ACTIVITY_ANIMATION_LENGTH: float = 1.2 +const COLLISION_MOTION_SAMPLE_DISTANCE: float = 8.0 +const RESEARCHER_TEXTURE: Texture2D = preload("res://assets/characters/generated/whale_researcher_v2/final_no_feet/processed/whale_researcher_no_feet_spritesheet.png") +const MAYOR_TEXTURE: Texture2D = preload("res://assets/characters/npc_286_241.png") +const CRAYFISH_TEXTURE: Texture2D = preload("res://assets/characters/crayfish_npc_256_256.png") +const NIULAI_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_spritesheet.png") +const NIULAI_IDLE_DOWN_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_down.png") +const NIULAI_IDLE_UP_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_up.png") +const NIULAI_IDLE_RIGHT_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_right.png") +const NIULAI_IDLE_LEFT_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_left.png") +var lastDirection: String = "down" +var visualScene: String = "" +var spriteColumns: int = 8 +var _lastBlockedActionId: String = "" +var _baseSpritePosition: Vector2 = Vector2.ZERO +var _niulaiIdleTexture: Texture2D +var _baseSpriteScale: Vector2 = Vector2.ONE +var _baseSpriteRotation: float = 0.0 + +func _ready() -> void: + super._ready() + var eventSystem := get_node_or_null("/root/EventSystem") + if eventSystem != null: + eventSystem.call("connect_event", EventNames.NPC_CONVERSATION, _on_npc_conversation, self) + if animation_player != null: + animation_player.stop() + var sharedLibrary := animation_player.get_animation_library("") + if sharedLibrary != null: + animation_player.remove_animation_library("") + animation_player.add_animation_library("", sharedLibrary.duplicate(true)) + _configure_visual("classic_whale") + +func _exit_tree() -> void: + var eventSystem := get_node_or_null("/root/EventSystem") + if eventSystem != null: + eventSystem.call("disconnect_event", EventNames.NPC_CONVERSATION, _on_npc_conversation, self) + +func interact() -> void: + # 第一次交互和后续交流都进入同一个居中对话框。 + var chatUi := get_tree().root.find_child("ChatUI", true, false) + if chatUi != null and chatUi.has_method("start_npc_whisper"): + chatUi.call("start_npc_whisper", npcId, npcName, dialogue) + +func _on_npc_conversation(data: Dictionary) -> void: + # 环境中的 NPC 对话只更新下次交互时的开场白,不再弹出世界气泡。 + var linesValue: Variant = data.get("lines", []) + if not (linesValue is Array): + return + for lineValue: Variant in (linesValue as Array): + if not (lineValue is Dictionary): + continue + var line: Dictionary = lineValue + if str(line.get("speaker_npc_id", "")) != npcId: + continue + var text := str(line.get("text", "")).strip_edges() + if not text.is_empty(): + dialogue = text + +func _process(delta: float) -> void: + if activeAction.is_empty(): + return + var actionKind := str(activeAction.get("kind", "walk")) + if actionKind == "transition": + return + var now := Time.get_ticks_msec() + var duration: int = maxi(1, _actionCompletesLocalMsec - _actionStartedLocalMsec) + var progress := clampf(float(now - _actionStartedLocalMsec) / float(duration), 0.0, 1.0) + var from := Vector2(float(activeAction.get("from_x", global_position.x)), float(activeAction.get("from_y", global_position.y))) + var to := Vector2(float(activeAction.get("to_x", global_position.x)), float(activeAction.get("to_y", global_position.y))) + var reachedAuthoritativePosition := _move_to_authoritative_position(from.lerp(to, progress)) + _update_world_sort_z() + if progress >= 1.0: + if reachedAuthoritativePosition and global_position.distance_to(to) <= 0.5: + global_position = to + activeAction = {} + movementState = "idle" + _play_idle_animation() + +func _play_idle_animation() -> void: + if animation_player != null: + if visualScene == "niulai_ambassador" and has_node("Sprite2D"): + _set_niulai_idle_texture("down") + animation_player.play("idle_down") + else: + animation_player.play("idle") + +func _play_activity_animation(activityKind: String = "") -> void: + if animation_player == null: + return + if visualScene == "niulai_ambassador" and has_node("Sprite2D"): + _set_niulai_idle_texture("down") + animation_player.play("idle_down") + return + var normalized := activityKind.strip_edges().to_lower() + var animationPrefix := "activity_work" + if normalized == "socialize" or normalized == "share": + animationPrefix = "activity_talk" + var animationName := "%s_%s" % [animationPrefix, lastDirection] + if animation_player.has_animation(animationName): + animation_player.play(animationName) + else: + _play_idle_animation() + +func _set_niulai_idle_texture(direction: String) -> void: + var idleTexture: Texture2D = NIULAI_IDLE_DOWN_TEXTURE + match direction: + "up": idleTexture = NIULAI_IDLE_UP_TEXTURE + "right": idleTexture = NIULAI_IDLE_RIGHT_TEXTURE + "left": idleTexture = NIULAI_IDLE_LEFT_TEXTURE + $Sprite2D.texture = idleTexture + $Sprite2D.hframes = 4 + $Sprite2D.vframes = 1 + +func _configure_visual(sceneKey: String) -> void: + if not has_node("Sprite2D"): + return + var normalized := sceneKey.strip_edges() + if normalized.is_empty(): + normalized = "classic_whale" + if visualScene == normalized: + return + visualScene = normalized + var sprite := $Sprite2D as Sprite2D + # Network NPC sheets are authored for crisp 2D rendering. Set the filter on + # the actual Sprite2D as well as the parent, since imported textures may + # otherwise fall back to the renderer's linear sampler on Web. + sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + sprite.texture_repeat = CanvasItem.TEXTURE_REPEAT_DISABLED + match visualScene: + "town_mayor": + sprite.texture = MAYOR_TEXTURE + spriteColumns = 4 + sprite.position = Vector2.ZERO + sprite.scale = Vector2(0.72, 0.72) + # The mayor frames have transparent padding above the propeller, so the + # name must follow the visible silhouette instead of the frame boundary. + nameplateOffsetY = -52.0 + _set_collision_size(Vector2(48, 24)) + "dock_crayfish": + sprite.texture = CRAYFISH_TEXTURE + spriteColumns = 4 + sprite.position = Vector2.ZERO + sprite.scale = Vector2(0.65, 0.65) + nameplateOffsetY = -66.0 + _set_collision_size(Vector2(44, 22)) + "niulai_ambassador": + sprite.texture = NIULAI_TEXTURE + spriteColumns = 4 + # The generated sheet includes feet. Align the sprite's feet with the + # network node origin used for sorting and collision. + # Its source frames are 384x256 (larger than the 160x160 sheets used + # by the other NPCs), so use a smaller display scale while preserving + # the same ground anchor and a slightly broader physical footprint. + sprite.position = Vector2(0, -45) + sprite.scale = Vector2(0.4, 0.4) + nameplateOffsetY = -105.0 + _set_collision_size(Vector2(52, 24)) + _: + sprite.texture = RESEARCHER_TEXTURE + spriteColumns = 8 + sprite.position = Vector2(0, -29) + sprite.scale = Vector2(0.5, 0.5) + nameplateOffsetY = -72.0 + _set_collision_size(Vector2(48, 24)) + _baseSpritePosition = sprite.position + _baseSpriteScale = sprite.scale + _baseSpriteRotation = sprite.rotation + sprite.hframes = spriteColumns + sprite.vframes = 4 + _configure_directional_animations() + _play_idle_animation() + _update_nameplate() + +func _set_collision_size(value: Vector2) -> void: + var collision := get_node_or_null("CollisionShape2D") as CollisionShape2D + if collision != null and collision.shape is RectangleShape2D: + collision.shape = collision.shape.duplicate() + (collision.shape as RectangleShape2D).size = value + +func _direction_row(direction: String) -> int: + if visualScene == "niulai_ambassador": + return int({"down": 0, "up": 1, "right": 2, "left": 3}.get(direction, 0)) + if spriteColumns == 4: + return int({"down": 0, "right": 1, "up": 2, "left": 3}.get(direction, 0)) + return int(DIRECTION_ROWS.get(direction, 0)) + +func _configure_directional_animations() -> void: + if animation_player == null or not has_node("Sprite2D"): + return + var sprite := $Sprite2D as Sprite2D + var library := animation_player.get_animation_library("") + if library == null: + library = AnimationLibrary.new() + animation_player.add_animation_library("", library) + if library.has_animation("idle"): + library.remove_animation("idle") + var idle := Animation.new() + idle.resource_name = "idle" + idle.length = IDLE_ANIMATION_LENGTH + idle.loop_mode = Animation.LOOP_LINEAR + var idleTrack := idle.add_track(Animation.TYPE_VALUE) + idle.track_set_path(idleTrack, NodePath("Sprite2D:frame")) + idle.value_track_set_update_mode(idleTrack, Animation.UPDATE_DISCRETE) + var idleFrames: Array[int] = [0, 1, 0, 2, 0] + if visualScene == "niulai_ambassador": + sprite.texture = NIULAI_IDLE_DOWN_TEXTURE + idleFrames = [0, 1, 2, 3, 2, 1, 0] + if spriteColumns >= 8: + idleFrames = [0, 2, 0, 4, 0] + for frameIndex in range(idleFrames.size()): + var frameTime := idle.length * float(frameIndex) / float(idleFrames.size() - 1) + idle.track_insert_key(idleTrack, frameTime, idleFrames[frameIndex]) + var idlePositionTrack := idle.add_track(Animation.TYPE_VALUE) + idle.track_set_path(idlePositionTrack, NodePath("Sprite2D:position")) + idle.track_insert_key(idlePositionTrack, 0.0, _baseSpritePosition) + idle.track_insert_key(idlePositionTrack, idle.length * 0.25, _baseSpritePosition + Vector2(0.0, -0.75)) + idle.track_insert_key(idlePositionTrack, idle.length * 0.5, _baseSpritePosition) + idle.track_insert_key(idlePositionTrack, idle.length * 0.75, _baseSpritePosition + Vector2(0.0, -0.35)) + idle.track_insert_key(idlePositionTrack, idle.length, _baseSpritePosition) + library.add_animation("idle", idle) + if visualScene == "niulai_ambassador": + sprite.texture = NIULAI_TEXTURE + sprite.hframes = 4 + sprite.vframes = 4 + for direction in DIRECTION_ROWS.keys(): + var row := _direction_row(str(direction)) + if visualScene == "niulai_ambassador": + var idleTextureForDirection: Texture2D = NIULAI_IDLE_DOWN_TEXTURE + match str(direction): + "up": idleTextureForDirection = NIULAI_IDLE_UP_TEXTURE + "right": idleTextureForDirection = NIULAI_IDLE_RIGHT_TEXTURE + "left": idleTextureForDirection = NIULAI_IDLE_LEFT_TEXTURE + sprite.texture = idleTextureForDirection + sprite.hframes = 4 + sprite.vframes = 1 + var niulaiIdleName := "idle_%s" % direction + if library.has_animation(niulaiIdleName): library.remove_animation(niulaiIdleName) + var niulaiIdle := Animation.new() + niulaiIdle.resource_name = niulaiIdleName + niulaiIdle.length = IDLE_ANIMATION_LENGTH + niulaiIdle.loop_mode = Animation.LOOP_LINEAR + var niulaiIdleTrack := niulaiIdle.add_track(Animation.TYPE_VALUE) + niulaiIdle.track_set_path(niulaiIdleTrack, NodePath("Sprite2D:frame")) + niulaiIdle.value_track_set_update_mode(niulaiIdleTrack, Animation.UPDATE_DISCRETE) + for niulaiFrameIndex in range(idleFrames.size()): + var niulaiFrameTime := niulaiIdle.length * float(niulaiFrameIndex) / float(idleFrames.size() - 1) + niulaiIdle.track_insert_key(niulaiIdleTrack, niulaiFrameTime, idleFrames[niulaiFrameIndex]) + library.add_animation(niulaiIdleName, niulaiIdle) + var niulaiWalkName := "walk_%s" % direction + if library.has_animation(niulaiWalkName): library.remove_animation(niulaiWalkName) + var niulaiWalk := Animation.new() + niulaiWalk.resource_name = niulaiWalkName + niulaiWalk.length = WALK_ANIMATION_LENGTH + niulaiWalk.loop_mode = Animation.LOOP_LINEAR + var niulaiWalkTrack := niulaiWalk.add_track(Animation.TYPE_VALUE) + niulaiWalk.track_set_path(niulaiWalkTrack, NodePath("Sprite2D:frame")) + niulaiWalk.value_track_set_update_mode(niulaiWalkTrack, Animation.UPDATE_DISCRETE) + for column in range(4): + niulaiWalk.track_insert_key( + niulaiWalkTrack, + float(column) * WALK_ANIMATION_LENGTH / 4.0, + row * 4 + column, + ) + library.add_animation(niulaiWalkName, niulaiWalk) + for staticPrefix in ["activity_work", "activity_talk"]: + var staticName := "%s_%s" % [staticPrefix, direction] + if library.has_animation(staticName): library.remove_animation(staticName) + var staticAnimation := Animation.new() + staticAnimation.resource_name = staticName + staticAnimation.length = 1.0 + staticAnimation.loop_mode = Animation.LOOP_LINEAR + var staticTrack := staticAnimation.add_track(Animation.TYPE_VALUE) + staticAnimation.track_set_path(staticTrack, NodePath("Sprite2D:frame")) + staticAnimation.value_track_set_update_mode(staticTrack, Animation.UPDATE_DISCRETE) + staticAnimation.track_insert_key(staticTrack, 0.0, row * spriteColumns) + library.add_animation(staticName, staticAnimation) + continue + var walkName := "walk_%s" % direction + if library.has_animation(walkName): library.remove_animation(walkName) + var walk := Animation.new() + walk.length = WALK_ANIMATION_LENGTH + walk.loop_mode = Animation.LOOP_LINEAR + var walkTrack := walk.add_track(Animation.TYPE_VALUE) + walk.track_set_path(walkTrack, NodePath("Sprite2D:frame")) + walk.value_track_set_update_mode(walkTrack, Animation.UPDATE_DISCRETE) + for column in range(spriteColumns): + walk.track_insert_key(walkTrack, float(column) * WALK_ANIMATION_LENGTH / float(spriteColumns), row * spriteColumns + column) + library.add_animation(walkName, walk) + + # Activity animations deliberately reuse the canonical character frame. + # Only the timing and a tiny pixel-scale body motion change, so every NPC + # keeps the exact same face, outfit, proportions, and palette while working. + for activityPrefix in ["activity_work", "activity_talk"]: + var activityName := "%s_%s" % [activityPrefix, direction] + if library.has_animation(activityName): library.remove_animation(activityName) + var activity := Animation.new() + activity.length = ACTIVITY_ANIMATION_LENGTH + activity.loop_mode = Animation.LOOP_LINEAR + var frameTrack := activity.add_track(Animation.TYPE_VALUE) + activity.track_set_path(frameTrack, NodePath("Sprite2D:frame")) + activity.value_track_set_update_mode(frameTrack, Animation.UPDATE_DISCRETE) + var motionFrames: Array[int] = [0, 1, 0, 2, 0] + if activityPrefix == "activity_talk": + motionFrames = [0, 2, 1, 3, 0] + if spriteColumns >= 8: + motionFrames = [0, 2, 0, 4, 0] + if activityPrefix == "activity_talk": + motionFrames = [0, 4, 2, 6, 0] + for frameIndex in range(motionFrames.size()): + var frameTime := ACTIVITY_ANIMATION_LENGTH * float(frameIndex) / float(motionFrames.size() - 1) + activity.track_insert_key(frameTrack, frameTime, row * spriteColumns + motionFrames[frameIndex]) + var positionTrack := activity.add_track(Animation.TYPE_VALUE) + activity.track_set_path(positionTrack, NodePath("Sprite2D:position")) + var bobAmount := 1.0 if activityPrefix == "activity_work" else 1.5 + activity.track_insert_key(positionTrack, 0.0, _baseSpritePosition) + activity.track_insert_key(positionTrack, ACTIVITY_ANIMATION_LENGTH * 0.25, _baseSpritePosition + Vector2(0.0, -bobAmount)) + activity.track_insert_key(positionTrack, ACTIVITY_ANIMATION_LENGTH * 0.5, _baseSpritePosition) + activity.track_insert_key(positionTrack, ACTIVITY_ANIMATION_LENGTH * 0.75, _baseSpritePosition + Vector2(0.0, -bobAmount * 0.5)) + activity.track_insert_key(positionTrack, ACTIVITY_ANIMATION_LENGTH, _baseSpritePosition) + var rotationTrack := activity.add_track(Animation.TYPE_VALUE) + activity.track_set_path(rotationTrack, NodePath("Sprite2D:rotation")) + var tiltAmount := 0.018 if activityPrefix == "activity_work" else 0.028 + activity.track_insert_key(rotationTrack, 0.0, _baseSpriteRotation) + activity.track_insert_key(rotationTrack, ACTIVITY_ANIMATION_LENGTH * 0.25, _baseSpriteRotation - tiltAmount) + activity.track_insert_key(rotationTrack, ACTIVITY_ANIMATION_LENGTH * 0.5, _baseSpriteRotation) + activity.track_insert_key(rotationTrack, ACTIVITY_ANIMATION_LENGTH * 0.75, _baseSpriteRotation + tiltAmount * 0.6) + activity.track_insert_key(rotationTrack, ACTIVITY_ANIMATION_LENGTH, _baseSpriteRotation) + var scaleTrack := activity.add_track(Animation.TYPE_VALUE) + activity.track_set_path(scaleTrack, NodePath("Sprite2D:scale")) + var scaleAmount := 0.012 if activityPrefix == "activity_work" else 0.018 + activity.track_insert_key(scaleTrack, 0.0, _baseSpriteScale) + activity.track_insert_key(scaleTrack, ACTIVITY_ANIMATION_LENGTH * 0.25, _baseSpriteScale * Vector2(1.0, 1.0 + scaleAmount)) + activity.track_insert_key(scaleTrack, ACTIVITY_ANIMATION_LENGTH * 0.5, _baseSpriteScale) + activity.track_insert_key(scaleTrack, ACTIVITY_ANIMATION_LENGTH * 0.75, _baseSpriteScale * Vector2(1.0, 1.0 + scaleAmount * 0.5)) + activity.track_insert_key(scaleTrack, ACTIVITY_ANIMATION_LENGTH, _baseSpriteScale) + library.add_animation(activityName, activity) + +func apply_snapshot(data: Dictionary) -> void: + var incomingVersion := int(data.get("version", 0)) + if stateVersion > incomingVersion: + return + var isInitialSnapshot := stateVersion < 0 + + npcId = str(data.get("npc_id", data.get("npcId", npcId))).strip_edges() + _configure_visual(str(data.get("scene", "classic_whale"))) + stateVersion = incomingVersion + npcName = str(data.get("name", npcName)).strip_edges() + worldState = str(data.get("state", "idle")).strip_edges() + publicIntention = str(data.get("public_intention", data.get("publicIntention", ""))).strip_edges() + dailyGoal = str(data.get("daily_goal", data.get("dailyGoal", ""))).strip_edges() + planSource = str(data.get("plan_source", data.get("planSource", "fallback"))).strip_edges() + var activityValue: Variant = data.get("current_activity", data.get("currentActivity", {})) + currentActivity = activityValue if activityValue is Dictionary else {} + dialogue = str(data.get("dialogue", _fallback_dialogue())).strip_edges() + movementState = str(data.get("movement_state", data.get("movementState", "idle"))).strip_edges() + var snapshotDirection := str(data.get("direction", lastDirection)).strip_edges().to_lower() + if DIRECTION_ROWS.has(snapshotDirection): + lastDirection = snapshotDirection + showNameplate = true + var snapshotPosition := Vector2(float(data.get("x", global_position.x)), float(data.get("y", global_position.y))) + if isInitialSnapshot: + _place_at_clear_position(snapshotPosition) + else: + _move_to_authoritative_position(snapshotPosition) + _update_nameplate() + _update_world_sort_z() + var snapshotAction: Variant = data.get("active_action", data.get("activeAction", {})) + if snapshotAction is Dictionary and not (snapshotAction as Dictionary).is_empty(): + _apply_action(snapshotAction, int(data.get("server_now", 0))) + else: + activeAction = {} + if _is_activity_state(): + _play_activity_animation(str(currentActivity.get("activityKind", currentActivity.get("activity_kind", "")))) + else: + _play_idle_animation() + +func apply_action_started(data: Dictionary) -> void: + _apply_action(data.get("action", {}), int(data.get("server_now", data.get("serverNow", 0)))) + +func _is_activity_state() -> bool: + var normalizedState := worldState.strip_edges().to_lower() + if normalizedState in ["working", "talking", "performing", "acting", "socializing", "sharing"]: + return not currentActivity.is_empty() or normalizedState != "working" + return false + +func apply_action_completed(data: Dictionary) -> void: + var action: Variant = data.get("action", {}) + if not (action is Dictionary): + return + var completed: Dictionary = action + var actionKind := str(completed.get("kind", "walk")) + var incomingVersion := int(completed.get("version", 0)) + if incomingVersion < stateVersion: + return + stateVersion = incomingVersion + if actionKind == "transition": + visible = false + else: + var completedPosition := Vector2(float(completed.get("to_x", completed.get("toX", global_position.x))), float(completed.get("to_y", completed.get("toY", global_position.y)))) + if not _move_to_authoritative_position(completedPosition): + movementState = "idle" + _play_idle_animation() + return + _update_world_sort_z() + activeAction = {} + movementState = "idle" + _play_idle_animation() + +func _apply_action(value: Variant, serverNow: int) -> void: + if not (value is Dictionary): + return + var incoming: Dictionary = value + var incomingVersion := int(incoming.get("version", 0)) + if incomingVersion < stateVersion: + return + stateVersion = incomingVersion + activeAction = incoming + visible = true + var actionKind := str(incoming.get("kind", "walk")) + movementState = "walk" if actionKind == "walk" else "idle" + worldState = "travelling" if actionKind == "transition" else ("talking" if str(incoming.get("activity_kind", "")) == "socialize" else ("working" if actionKind == "perform" else "walking")) + var dx := float(incoming.get("to_x", incoming.get("toX", 0.0))) - float(incoming.get("from_x", incoming.get("fromX", 0.0))) + var dy := float(incoming.get("to_y", incoming.get("toY", 0.0))) - float(incoming.get("from_y", incoming.get("fromY", 0.0))) + if actionKind == "walk": + if absf(dx) > absf(dy): + lastDirection = "right" if dx >= 0.0 else "left" + else: + lastDirection = "down" if dy >= 0.0 else "up" + if animation_player != null and actionKind == "walk": + if visualScene == "niulai_ambassador" and has_node("Sprite2D"): + $Sprite2D.texture = NIULAI_TEXTURE + $Sprite2D.hframes = 4 + $Sprite2D.vframes = 4 + animation_player.play("walk_%s" % lastDirection) + elif animation_player != null and actionKind == "perform": + _play_activity_animation(str(incoming.get("activity_kind", incoming.get("activityKind", "")))) + elif animation_player != null: + _play_idle_animation() + var startedAt := int(incoming.get("started_at", incoming.get("startedAt", 0))) + var completesAt := int(incoming.get("completes_at", incoming.get("completesAt", startedAt))) + var offset := Time.get_ticks_msec() - serverNow if serverNow > 0 else 0 + _actionStartedLocalMsec = startedAt + offset + _actionCompletesLocalMsec = completesAt + offset + if actionKind != "transition": + var from := Vector2(float(incoming.get("from_x", incoming.get("fromX", global_position.x))), float(incoming.get("from_y", incoming.get("fromY", global_position.y)))) + var to := Vector2(float(incoming.get("to_x", incoming.get("toX", from.x))), float(incoming.get("to_y", incoming.get("toY", from.y)))) + var duration: int = maxi(1, _actionCompletesLocalMsec - _actionStartedLocalMsec) + var progress := clampf(float(Time.get_ticks_msec() - _actionStartedLocalMsec) / float(duration), 0.0, 1.0) + _move_to_authoritative_position(from.lerp(to, progress)) + _update_world_sort_z() + +func _move_to_authoritative_position(target: Vector2) -> bool: + # Network NPC positions are authoritative. Static map collisions are already + # resolved by the server's route graph; blocking the replicated position on + # the client makes every NPC freeze when a decorative collider is slightly + # offset from a route point. Keep the shape for interaction, but never reject + # an authoritative movement update locally. + global_position = target + return true + +func _place_at_clear_position(target: Vector2) -> bool: + global_position = target + return true + +func _has_static_collision_at(target: Vector2) -> bool: + var collision := get_node_or_null("CollisionShape2D") as CollisionShape2D + if collision == null or collision.shape == null or not is_inside_tree(): + return false + var query := PhysicsShapeQueryParameters2D.new() + query.shape = collision.shape + var candidateTransform := collision.global_transform + candidateTransform.origin += target - global_position + query.transform = candidateTransform + query.collision_mask = 1 + query.collide_with_areas = false + query.collide_with_bodies = true + query.exclude = [get_rid()] + for hit in get_world_2d().direct_space_state.intersect_shape(query, 16): + if hit.get("collider") is StaticBody2D: + return true + return false + +func _report_blocked_route(position: Vector2) -> void: + var actionId := str(activeAction.get("action_id", activeAction.get("actionId", "snapshot"))) + if actionId == _lastBlockedActionId: + return + _lastBlockedActionId = actionId + push_warning("NetworkNpc route blocked by static collision: npc=%s action=%s position=%s" % [npcId, actionId, position]) + +func _fallback_dialogue() -> String: + if not publicIntention.is_empty(): + return "你好,我是%s。%s。" % [npcName, publicIntention] + return "你好,我是%s。" % npcName diff --git a/scenes/characters/NetworkNpc.gd.uid b/scenes/characters/NetworkNpc.gd.uid new file mode 100644 index 0000000..fadd7ed --- /dev/null +++ b/scenes/characters/NetworkNpc.gd.uid @@ -0,0 +1 @@ +uid://bfkuofgw3ftnp diff --git a/scenes/characters/PlayerController.gd b/scenes/characters/PlayerController.gd index f46e862..acf535a 100644 --- a/scenes/characters/PlayerController.gd +++ b/scenes/characters/PlayerController.gd @@ -6,10 +6,23 @@ signal player_moved(position: Vector2) # 常量定义 const MOVE_SPEED = 200.0 +const PLAYER_COLLISION_LAYER = 1 +const PLAYER_COLLISION_MASK = 3 const WORLD_SORT_Z_OFFSET = 2048 const INTERACTION_COLLISION_MASK = 2 const WALK_ANIMATION_LENGTH = 0.8 -const NAME_LABEL_OFFSET: Vector2 = Vector2(-70, -112) +const NAME_LABEL_RENDER_SCALE: float = 0.5 +const NAME_LABEL_FONT_SIZE: int = 16 +const NAME_LABEL_VISUAL_HEIGHT: int = 15 +const NAME_LABEL_VISUAL_MIN_WIDTH: int = 64 +const NAME_LABEL_VISUAL_MAX_WIDTH: int = 100 +const NAME_LABEL_VISUAL_CHAR_WIDTH: int = 10 +const NAME_LABEL_OFFSET_Y: float = -72.0 +const NAME_LABEL_SIDE_OFFSET_Y: float = -84.0 +const NAME_LABEL_BACK_OFFSET_Y: float = -78.0 +const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres") +const NAME_LABEL_FONT_ZH: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2") +const NAME_LABEL_FONT_LATIN: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2") const DIRECTION_ROWS: Dictionary = { "down": 0, "up": 1, @@ -24,9 +37,32 @@ const DIRECTION_ROWS: Dictionary = { var lastDirection: String = "down" var _nameLabel: Label +var _nicknameFont: FontFile +var _cachedNameLabelText: String = "" +var _cachedNameLabelDirection: String = "" +var _cachedNameLabelVisible: bool = false var _movementLocked: bool = false +var _lastEmittedMovementState: String = "idle" +var _spectator_mode: bool = false + +func set_spectator_mode(enabled: bool) -> void: + _spectator_mode = enabled + if is_instance_valid(sprite): + sprite.visible = not enabled + if is_instance_valid(_nameLabel): + _nameLabel.visible = not enabled + if is_instance_valid(ray_cast): + ray_cast.enabled = not enabled + if enabled: + collision_layer = 0 + collision_mask = 0 + else: + collision_layer = PLAYER_COLLISION_LAYER + collision_mask = PLAYER_COLLISION_MASK func _ready() -> void: + collision_layer = PLAYER_COLLISION_LAYER + collision_mask = PLAYER_COLLISION_MASK _reset_movement_input_state() _apply_current_appearance() _subscribe_to_appearance_events() @@ -81,6 +117,8 @@ func _physics_process(delta: float) -> void: _handle_interaction() func _handle_interaction() -> void: + if _spectator_mode: + return if _is_text_input_focused(): return if Input.is_action_just_pressed("interact"): @@ -100,6 +138,7 @@ func _handle_movement(_delta: float) -> void: velocity = Vector2.ZERO _play_idle_animation() move_and_slide() + _emit_movement_sync("idle") return # 输入框获得焦点时禁止移动,避免聊天/表单输入影响角色 @@ -108,6 +147,7 @@ func _handle_movement(_delta: float) -> void: velocity = Vector2.ZERO _play_idle_animation() move_and_slide() + _emit_movement_sync("idle") return # 获取移动向量 (参考 docs/02-开发规范/输入映射配置.md) @@ -126,17 +166,28 @@ func _handle_movement(_delta: float) -> void: move_and_slide() - # 发送移动事件 (如果位置发生明显变化) - if velocity.length() > 0: + # 移动中持续发送位置;停止时额外发送一次 idle,供远端切回待机动画。 + var movementState := "walk" if velocity.length() > 0 else "idle" + _emit_movement_sync(movementState) + +func _emit_movement_sync(movementState: String) -> void: + if _spectator_mode: + return + if movementState == "walk": player_moved.emit(global_position) + if movementState == "walk" or movementState != _lastEmittedMovementState: EventSystem.emit_event(EventNames.PLAYER_MOVED, { - "position": global_position + "position": global_position, + "direction": lastDirection, + "movement_state": movementState }) + _lastEmittedMovementState = movementState func _update_animation_state(direction: Vector2) -> void: if not animation_player: return + var previousDirection := lastDirection # Determine primary direction if abs(direction.x) > abs(direction.y): if direction.x > 0: @@ -153,6 +204,8 @@ func _update_animation_state(direction: Vector2) -> void: lastDirection = "up" ray_cast.target_position = Vector2(0, -60) + if lastDirection != previousDirection: + _update_name_label() animation_player.play("walk_" + lastDirection) func _play_idle_animation() -> void: @@ -241,19 +294,61 @@ func _create_name_label() -> void: return _nameLabel = Label.new() _nameLabel.name = "NameLabel" - _nameLabel.position = NAME_LABEL_OFFSET - _nameLabel.custom_minimum_size = Vector2(140, 28) - _nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _nameLabel.add_theme_color_override("font_color", Color(0.188, 0.294, 0.424)) - _nameLabel.add_theme_font_size_override("font_size", 14) - _nameLabel.add_theme_stylebox_override("normal", _create_name_label_style()) add_child(_nameLabel) func _update_name_label() -> void: if not is_instance_valid(_nameLabel): return - _nameLabel.text = _current_username() - _nameLabel.visible = _settings_bool("show_name_always", false) + var displayName := _current_username() + var showName := _settings_bool("show_name_always", false) + if displayName == _cachedNameLabelText and lastDirection == _cachedNameLabelDirection and showName == _cachedNameLabelVisible: + return + var visualWidth := _name_label_visual_width(displayName) + var renderWidth := ceili(float(visualWidth) / NAME_LABEL_RENDER_SCALE) + var renderHeight := ceili(float(NAME_LABEL_VISUAL_HEIGHT) / NAME_LABEL_RENDER_SCALE) + + _nameLabel.theme = WORLD_TEXT_THEME + _nameLabel.text = displayName + _nameLabel.visible = showName + _nameLabel.z_index = 30 + _nameLabel.scale = Vector2.ONE * NAME_LABEL_RENDER_SCALE + _nameLabel.position = Vector2(float(visualWidth) * -0.5, _name_label_offset_y()) + _nameLabel.custom_minimum_size = Vector2(renderWidth, renderHeight) + _nameLabel.size = _nameLabel.custom_minimum_size + _nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _nameLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _nameLabel.clip_text = true + _nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + _nameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE + _nameLabel.add_theme_color_override("font_color", Color(0.09, 0.25, 0.31, 1.0)) + _nameLabel.add_theme_color_override("font_outline_color", Color(1.0, 0.965, 0.88, 0.98)) + _nameLabel.add_theme_constant_override("outline_size", 3) + _nameLabel.add_theme_font_override("font", _get_nickname_font()) + _nameLabel.add_theme_font_size_override("font_size", NAME_LABEL_FONT_SIZE) + _nameLabel.add_theme_stylebox_override("normal", StyleBoxEmpty.new()) + _cachedNameLabelText = displayName + _cachedNameLabelDirection = lastDirection + _cachedNameLabelVisible = showName + +func _name_label_visual_width(displayName: String) -> int: + var measuredWidth := _get_nickname_font().get_string_size(displayName, HORIZONTAL_ALIGNMENT_LEFT, -1, NAME_LABEL_FONT_SIZE).x + var visualWidth := ceili(measuredWidth * NAME_LABEL_RENDER_SCALE + 20.0) + return clampi(visualWidth, NAME_LABEL_VISUAL_MIN_WIDTH, NAME_LABEL_VISUAL_MAX_WIDTH) + +func _get_nickname_font() -> FontFile: + if _nicknameFont == null: + _nicknameFont = NAME_LABEL_FONT_ZH.duplicate() as FontFile + _nicknameFont.fallbacks = [NAME_LABEL_FONT_LATIN] + return _nicknameFont + +func _name_label_offset_y() -> float: + match lastDirection: + "left", "right": + return NAME_LABEL_SIDE_OFFSET_Y + "up": + return NAME_LABEL_BACK_OFFSET_Y + _: + return NAME_LABEL_OFFSET_Y func _current_username() -> String: var authManager := get_node_or_null("/root/AuthManager") @@ -268,16 +363,3 @@ func _settings_bool(key: String, defaultValue: bool) -> bool: if settingsManager != null and settingsManager.has_method("get_bool"): return bool(settingsManager.call("get_bool", key)) return defaultValue - -func _create_name_label_style() -> StyleBoxFlat: - var style := StyleBoxFlat.new() - style.bg_color = Color(1.0, 1.0, 1.0, 0.74) - style.corner_radius_top_left = 10 - style.corner_radius_top_right = 10 - style.corner_radius_bottom_left = 10 - style.corner_radius_bottom_right = 10 - style.content_margin_left = 8 - style.content_margin_right = 8 - style.content_margin_top = 4 - style.content_margin_bottom = 4 - return style diff --git a/scenes/characters/RemotePlayer.gd b/scenes/characters/RemotePlayer.gd index 3395d27..baac5a1 100644 --- a/scenes/characters/RemotePlayer.gd +++ b/scenes/characters/RemotePlayer.gd @@ -7,17 +7,35 @@ class_name RemotePlayer var userId: String = "" var username: String = "" -var skinId: String = "" +const DEFAULT_REMOTE_SKIN_ID: String = "classic_whale" + +var skinId: String = DEFAULT_REMOTE_SKIN_ID var skinAsset: Dictionary = {} var avatarId: String = "" var targetPosition: Vector2 = Vector2.ZERO var cafeCompanionData: Dictionary = {} +var movementState: String = "idle" +var lastSequence: int = -1 +var _hasSyncedDirection: bool = false +var _nicknameFont: FontFile +var _cachedNameLabelText: String = "" +var _cachedNameLabelPersona: String = "" +var _cachedNameLabelDirection: String = "" +var _cachedNameLabelVisible: bool = false # 内部状态 var lastDirection: String = "down" const WORLD_SORT_Z_OFFSET = 2048 const WALK_ANIMATION_LENGTH = 0.8 -const NAME_LABEL_OFFSET: Vector2 = Vector2(-70, -112) +const NAME_LABEL_RENDER_SCALE: float = 0.5 +const NAME_LABEL_FONT_SIZE: int = 16 +const NAME_LABEL_VISUAL_HEIGHT: int = 15 +const NAME_LABEL_VISUAL_MIN_WIDTH: int = 64 +const NAME_LABEL_VISUAL_MAX_WIDTH: int = 100 +const NAME_LABEL_VISUAL_CHAR_WIDTH: int = 10 +const NAME_LABEL_OFFSET_Y: float = -72.0 +const NAME_LABEL_SIDE_OFFSET_Y: float = -84.0 +const NAME_LABEL_BACK_OFFSET_Y: float = -78.0 const CAFE_NAME_LABEL_RENDER_SCALE: float = 0.5 const CAFE_NAME_LABEL_FONT_SIZE: int = 24 const CAFE_NAME_LABEL_VISUAL_HEIGHT: int = 22 @@ -26,6 +44,8 @@ const CAFE_NAME_LABEL_VISUAL_MAX_WIDTH: int = 118 const CAFE_NAME_LABEL_VISUAL_CHAR_WIDTH: int = 12 const CAFE_NAME_LABEL_OFFSET_Y: float = -96.0 const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres") +const NAME_LABEL_FONT_ZH: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2") +const NAME_LABEL_FONT_LATIN: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2") const DIRECTION_ROWS: Dictionary = { "down": 0, "up": 1, @@ -73,37 +93,15 @@ func _process(delta: float) -> void: global_position = newPos _update_world_sort_z() else: - # 距离很近时直接吸附并播放待机动画 + # 距离很近时吸附;动画状态由发送端的 idle/walk 决定。 global_position = targetPosition _update_world_sort_z() - _play_idle_animation() + _play_current_animation() # 统一初始化方法 # data: 包含 camelCase 字段的字典 (userId, username, position 等) func setup(data: Dictionary) -> void: - if data.has("userId"): - userId = data.userId - if data.has("username"): - username = str(data.username) - if data.has("skin_id"): - skinId = str(data.get("skin_id", "")) - elif data.has("skinId"): - skinId = str(data.get("skinId", "")) - if data.has("skin_asset"): - var skinAssetData: Variant = data.get("skin_asset", {}) - skinAsset = skinAssetData if skinAssetData is Dictionary else {} - elif data.has("skinAsset"): - var skinAssetPayload: Variant = data.get("skinAsset", {}) - skinAsset = skinAssetPayload if skinAssetPayload is Dictionary else {} - if data.has("avatar_id"): - avatarId = str(data.get("avatar_id", "")) - elif data.has("avatarId"): - avatarId = str(data.get("avatarId", "")) - if data.has("cafe_companion") or data.has("cafeCompanion"): - cafeCompanionData = _normalize_cafe_companion_data(data.get("cafe_companion", data.get("cafeCompanion", null))) - _apply_appearance() - _configure_cafe_companion_target() - _update_name_label() + update_metadata(data) if data.has("position"): var positionData: Variant = data.position @@ -111,15 +109,73 @@ func setup(data: Dictionary) -> void: global_position = positionData targetPosition = positionData _update_world_sort_z() - elif positionData.has("x") and positionData.has("y"): + elif positionData is Dictionary and positionData.has("x") and positionData.has("y"): var newPos := Vector2(positionData.x, positionData.y) global_position = newPos targetPosition = newPos _update_world_sort_z() + _apply_movement_state(data) + +func update_metadata(data: Dictionary) -> void: + var appearanceChanged := false + var companionChanged := false + if data.has("userId"): + userId = data.userId + if data.has("username"): + username = str(data.username) + if data.has("skin_id"): + var nextSkinId := _normalize_remote_skin_id(str(data.get("skin_id", ""))) + appearanceChanged = appearanceChanged or nextSkinId != skinId + skinId = nextSkinId + elif data.has("skinId"): + var nextSkinId := _normalize_remote_skin_id(str(data.get("skinId", ""))) + appearanceChanged = appearanceChanged or nextSkinId != skinId + skinId = nextSkinId + if data.has("skin_asset"): + var skinAssetData: Variant = data.get("skin_asset", {}) + var nextSkinAsset: Dictionary = skinAssetData if skinAssetData is Dictionary else {} + appearanceChanged = appearanceChanged or nextSkinAsset != skinAsset + skinAsset = nextSkinAsset + elif data.has("skinAsset"): + var skinAssetPayload: Variant = data.get("skinAsset", {}) + var nextSkinAsset: Dictionary = skinAssetPayload if skinAssetPayload is Dictionary else {} + appearanceChanged = appearanceChanged or nextSkinAsset != skinAsset + skinAsset = nextSkinAsset + if data.has("avatar_id"): + avatarId = str(data.get("avatar_id", "")) + elif data.has("avatarId"): + avatarId = str(data.get("avatarId", "")) + if data.has("cafe_companion") or data.has("cafeCompanion"): + var nextCompanionData := _normalize_cafe_companion_data(data.get("cafe_companion", data.get("cafeCompanion", null))) + companionChanged = nextCompanionData != cafeCompanionData + cafeCompanionData = nextCompanionData + if appearanceChanged: + _apply_appearance() + if companionChanged: + _configure_cafe_companion_target() + _update_name_label() + +func _normalize_remote_skin_id(value: String) -> String: + var normalized := value.strip_edges() + if normalized.is_empty() or normalized == "pending_initial_skin": + return DEFAULT_REMOTE_SKIN_ID + return normalized # 更新目标位置 -func update_position(newPos: Vector2) -> void: +func update_position(newPos: Vector2, direction: String = "", nextMovementState: String = "walk", sequence: int = -1) -> void: + if sequence >= 0 and lastSequence >= 0 and sequence <= lastSequence: + return + if sequence >= 0: + lastSequence = sequence + var normalizedDirection := _normalize_direction(direction) + if not normalizedDirection.is_empty(): + lastDirection = normalizedDirection + _hasSyncedDirection = true + _update_name_label() + movementState = "walk" if nextMovementState.strip_edges().to_lower() == "walk" else "idle" targetPosition = newPos + if global_position.distance_to(targetPosition) <= 1.0: + _play_current_animation() func _update_world_sort_z() -> void: z_index = WORLD_SORT_Z_OFFSET + int(round(global_position.y)) @@ -128,20 +184,43 @@ func _update_animation(moveVec: Vector2) -> void: if not animation_player: return - # 确定主方向 - if abs(moveVec.x) > abs(moveVec.y): - if moveVec.x > 0: - lastDirection = "right" + # 新协议使用发送端方向;兼容旧位置包时再从位移向量推断。 + if not _hasSyncedDirection: + if abs(moveVec.x) > abs(moveVec.y): + if moveVec.x > 0: + lastDirection = "right" + else: + lastDirection = "left" else: - lastDirection = "left" - else: - if moveVec.y > 0: - lastDirection = "down" - else: - lastDirection = "up" + if moveVec.y > 0: + lastDirection = "down" + else: + lastDirection = "up" animation_player.play("walk_" + lastDirection) +func _apply_movement_state(data: Dictionary) -> void: + var normalizedDirection := _normalize_direction(str(data.get("direction", ""))) + if not normalizedDirection.is_empty(): + lastDirection = normalizedDirection + _hasSyncedDirection = true + _update_name_label() + movementState = "walk" if str(data.get("movement_state", data.get("movementState", "idle"))).strip_edges().to_lower() == "walk" else "idle" + lastSequence = int(data.get("sequence", lastSequence)) + _play_current_animation() + +func _normalize_direction(value: String) -> String: + var normalized := value.strip_edges().to_lower() + return normalized if normalized in ["down", "up", "right", "left"] else "" + +func _play_current_animation() -> void: + if animation_player == null: + return + if movementState == "walk": + animation_player.play("walk_" + lastDirection) + else: + _play_idle_animation() + func _play_idle_animation() -> void: if animation_player: animation_player.play("idle_" + lastDirection) @@ -206,25 +285,26 @@ func _create_name_label() -> void: return _nameLabel = Label.new() _nameLabel.name = "NameLabel" - _nameLabel.position = NAME_LABEL_OFFSET - _nameLabel.custom_minimum_size = Vector2(140, 28) - _nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _nameLabel.add_theme_color_override("font_color", Color(0.188, 0.294, 0.424)) - _nameLabel.add_theme_font_size_override("font_size", 14) - _nameLabel.add_theme_stylebox_override("normal", _create_name_label_style()) add_child(_nameLabel) func _update_name_label() -> void: if not is_instance_valid(_nameLabel): return var personaName := str(cafeCompanionData.get("persona_name", "")).strip_edges() + var displayName := personaName if not personaName.is_empty() else (username if not username.strip_edges().is_empty() else "玩家") + var showName := true if not personaName.is_empty() else (_is_guest_mode() or _settings_bool("show_name_always", false)) + if displayName == _cachedNameLabelText and personaName == _cachedNameLabelPersona and lastDirection == _cachedNameLabelDirection and showName == _cachedNameLabelVisible: + return if not personaName.is_empty(): _configure_cafe_name_label(personaName) - _nameLabel.visible = true - return - _configure_default_name_label() - _nameLabel.text = username if not username.strip_edges().is_empty() else "玩家" - _nameLabel.visible = _settings_bool("show_name_always", false) + else: + _configure_default_name_label() + _nameLabel.text = displayName + _nameLabel.visible = showName + _cachedNameLabelText = displayName + _cachedNameLabelPersona = personaName + _cachedNameLabelDirection = lastDirection + _cachedNameLabelVisible = showName func _configure_cafe_name_label(personaName: String) -> void: var displayName := personaName.strip_edges() @@ -246,30 +326,61 @@ func _configure_cafe_name_label(personaName: String) -> void: _nameLabel.clip_text = true _nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS _nameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE - _nameLabel.add_theme_color_override("font_color", Color(0.12, 0.20, 0.24, 1.0)) + _nameLabel.add_theme_color_override("font_color", Color(0.09, 0.25, 0.31, 1.0)) _nameLabel.add_theme_color_override("font_shadow_color", Color(1.0, 1.0, 1.0, 0.85)) _nameLabel.add_theme_constant_override("shadow_offset_x", 0) _nameLabel.add_theme_constant_override("shadow_offset_y", 1) + _nameLabel.remove_theme_color_override("font_outline_color") + _nameLabel.remove_theme_constant_override("outline_size") _nameLabel.add_theme_font_size_override("font_size", CAFE_NAME_LABEL_FONT_SIZE) _nameLabel.add_theme_stylebox_override("normal", _create_cafe_name_label_style()) func _configure_default_name_label() -> void: - _nameLabel.theme = null - _nameLabel.position = NAME_LABEL_OFFSET - _nameLabel.scale = Vector2.ONE - _nameLabel.custom_minimum_size = Vector2(140, 28) + var displayName := username if not username.strip_edges().is_empty() else "玩家" + var visualWidth := _name_label_visual_width(displayName) + var renderWidth := ceili(float(visualWidth) / NAME_LABEL_RENDER_SCALE) + var renderHeight := ceili(float(NAME_LABEL_VISUAL_HEIGHT) / NAME_LABEL_RENDER_SCALE) + + _nameLabel.theme = WORLD_TEXT_THEME + _nameLabel.z_index = 30 + _nameLabel.scale = Vector2.ONE * NAME_LABEL_RENDER_SCALE + _nameLabel.position = Vector2(float(visualWidth) * -0.5, _name_label_offset_y()) + _nameLabel.custom_minimum_size = Vector2(renderWidth, renderHeight) _nameLabel.size = _nameLabel.custom_minimum_size _nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _nameLabel.vertical_alignment = VERTICAL_ALIGNMENT_TOP - _nameLabel.clip_text = false - _nameLabel.text_overrun_behavior = TextServer.OVERRUN_NO_TRIMMING - _nameLabel.mouse_filter = Control.MOUSE_FILTER_STOP - _nameLabel.add_theme_color_override("font_color", Color(0.188, 0.294, 0.424)) + _nameLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _nameLabel.clip_text = true + _nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + _nameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE + _nameLabel.add_theme_color_override("font_color", Color(0.09, 0.25, 0.31, 1.0)) _nameLabel.remove_theme_color_override("font_shadow_color") _nameLabel.remove_theme_constant_override("shadow_offset_x") _nameLabel.remove_theme_constant_override("shadow_offset_y") - _nameLabel.add_theme_font_size_override("font_size", 14) - _nameLabel.add_theme_stylebox_override("normal", _create_name_label_style()) + _nameLabel.add_theme_color_override("font_outline_color", Color(1.0, 0.965, 0.88, 0.98)) + _nameLabel.add_theme_constant_override("outline_size", 3) + _nameLabel.add_theme_font_override("font", _get_nickname_font()) + _nameLabel.add_theme_font_size_override("font_size", NAME_LABEL_FONT_SIZE) + _nameLabel.add_theme_stylebox_override("normal", StyleBoxEmpty.new()) + +func _name_label_visual_width(displayName: String) -> int: + var measuredWidth := _get_nickname_font().get_string_size(displayName, HORIZONTAL_ALIGNMENT_LEFT, -1, NAME_LABEL_FONT_SIZE).x + var visualWidth := ceili(measuredWidth * NAME_LABEL_RENDER_SCALE + 20.0) + return clampi(visualWidth, NAME_LABEL_VISUAL_MIN_WIDTH, NAME_LABEL_VISUAL_MAX_WIDTH) + +func _get_nickname_font() -> FontFile: + if _nicknameFont == null: + _nicknameFont = NAME_LABEL_FONT_ZH.duplicate() as FontFile + _nicknameFont.fallbacks = [NAME_LABEL_FONT_LATIN] + return _nicknameFont + +func _name_label_offset_y() -> float: + match lastDirection: + "left", "right": + return NAME_LABEL_SIDE_OFFSET_Y + "up": + return NAME_LABEL_BACK_OFFSET_Y + _: + return NAME_LABEL_OFFSET_Y func _cafe_name_label_visual_width(displayName: String) -> int: var estimatedWidth := displayName.length() * CAFE_NAME_LABEL_VISUAL_CHAR_WIDTH + 28 @@ -340,18 +451,9 @@ func _settings_bool(key: String, defaultValue: bool) -> bool: return bool(settingsManager.call("get_bool", key)) return defaultValue -func _create_name_label_style() -> StyleBoxFlat: - var style := StyleBoxFlat.new() - style.bg_color = Color(1.0, 1.0, 1.0, 0.74) - style.corner_radius_top_left = 10 - style.corner_radius_top_right = 10 - style.corner_radius_bottom_left = 10 - style.corner_radius_bottom_right = 10 - style.content_margin_left = 8 - style.content_margin_right = 8 - style.content_margin_top = 4 - style.content_margin_bottom = 4 - return style +func _is_guest_mode() -> bool: + var chatManager := get_node_or_null("/root/ChatManager") + return chatManager != null and chatManager.has_method("is_guest_mode") and bool(chatManager.call("is_guest_mode")) func _create_cafe_name_label_style() -> StyleBoxFlat: var style := StyleBoxFlat.new() diff --git a/scenes/characters/network_npc.tscn b/scenes/characters/network_npc.tscn new file mode 100644 index 0000000..bca1e74 --- /dev/null +++ b/scenes/characters/network_npc.tscn @@ -0,0 +1,18 @@ +[gd_scene load_steps=4 format=3] + +[ext_resource type="PackedScene" path="res://scenes/characters/npc.tscn" id="1_base"] +[ext_resource type="Script" path="res://scenes/characters/NetworkNpc.gd" id="2_script"] +[ext_resource type="Texture2D" path="res://assets/characters/generated/whale_researcher_v2/final_no_feet/processed/whale_researcher_no_feet_spritesheet.png" id="3_researcher"] + +[node name="NetworkNpc" instance=ExtResource("1_base")] +script = ExtResource("2_script") +showNameplate = true +nameplateOffsetY = -72.0 + +[node name="Sprite2D" parent="." index="0"] +position = Vector2(0, -29) +scale = Vector2(0.5, 0.5) +texture_filter = 1 +texture = ExtResource("3_researcher") +hframes = 8 +vframes = 4 diff --git a/scenes/characters/player.tscn b/scenes/characters/player.tscn index 7ab9a76..2b8d8d2 100644 --- a/scenes/characters/player.tscn +++ b/scenes/characters/player.tscn @@ -157,6 +157,8 @@ _data = { [node name="Player" type="CharacterBody2D"] script = ExtResource("1_script") +collision_layer = 1 +collision_mask = 3 [node name="Sprite2D" type="Sprite2D" parent="."] texture_filter = 2 diff --git a/scenes/prefabs/ui/ChatMessage.gd b/scenes/prefabs/ui/ChatMessage.gd index a403a4e..d6af420 100644 --- a/scenes/prefabs/ui/ChatMessage.gd +++ b/scenes/prefabs/ui/ChatMessage.gd @@ -163,6 +163,41 @@ func set_message(from_user: String, content: String, timestamp: float, is_self: # 应用样式 _apply_style() +# NPC 会话由外层复古对话框承载,单条内容不再绘制聊天气泡。 +func set_dialogue_mode(enabled: bool) -> void: + if not enabled: + return + _cache_node_refs() + size_flags_horizontal = Control.SIZE_EXPAND_FILL + custom_minimum_size.y = 0.0 + if message_row: + message_row.alignment = BoxContainer.ALIGNMENT_BEGIN + message_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + message_row.add_theme_constant_override("separation", 0) + if left_avatar_panel: + left_avatar_panel.visible = false + if right_avatar_panel: + right_avatar_panel.visible = false + if bubble_panel: + bubble_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + bubble_panel.custom_minimum_size.x = 0.0 + bubble_panel.add_theme_stylebox_override("panel", StyleBoxEmpty.new()) + if text_container: + text_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL + text_container.add_theme_constant_override("separation", 4) + if user_info_container: + user_info_container.alignment = BoxContainer.ALIGNMENT_BEGIN + if username_label: + username_label.add_theme_color_override("font_color", Color(0.08, 0.24, 0.27, 1.0)) + username_label.add_theme_font_size_override("font_size", 16) + if timestamp_label: + timestamp_label.visible = false + if content_label: + content_label.custom_minimum_size.x = 0.0 + content_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + content_label.add_theme_color_override("default_color", Color(0.12, 0.18, 0.18, 1.0)) + content_label.add_theme_font_size_override("normal_font_size", 17) + # ============================================================================ # 内部方法 - 样式处理 # ============================================================================ diff --git a/scenes/ui/AuthScene.gd b/scenes/ui/AuthScene.gd index 0c80bcc..bb0161b 100644 --- a/scenes/ui/AuthScene.gd +++ b/scenes/ui/AuthScene.gd @@ -25,12 +25,12 @@ 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_IMAGE_PLACEHOLDER_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/image_placeholder_box.png" const UI_FONT_PATH: String = "res://assets/fonts/msyh-web.ttf" +const AVATAR_MASK_SHADER = preload("res://assets/shaders/avatar_round_mask.gdshader") const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd") const WebFilePicker = preload("res://_Core/utils/WebFilePicker.gd") 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_INTERVAL: float = 2.0 -const SKIN_GENERATION_REQUEST_TIMEOUT: float = 24.0 const AVATAR_NATIVE_FILE_FILTERS: Array[String] = ["*.png,*.jpg,*.jpeg,*.webp"] const SKIN_NATIVE_FILE_FILTERS: Array[String] = ["*.png"] const WEB_FILE_MAX_BYTES: int = 8 * 1024 * 1024 @@ -46,7 +46,7 @@ const AUTH_STAGE_VIEWPORT_RATIO: float = 0.92 const LOGIN_FRAME_RECT: Rect2 = Rect2(270, 40, 780, 680) const REGISTER_FRAME_RECT: Rect2 = Rect2(150, 15, 1020, 727) const LOGIN_FORM_RECT: Rect2 = Rect2(432, 210, 430, 430) -const REGISTER_FORM_RECT: Rect2 = Rect2(292, 155, 446, 500) +const REGISTER_FORM_RECT: Rect2 = Rect2(292, 128, 446, 500) @onready var whale_frame: TextureRect = %WhaleFrame @onready var login_panel: PanelContainer = %LoginPanel @@ -55,8 +55,10 @@ const REGISTER_FORM_RECT: Rect2 = Rect2(292, 155, 446, 500) @onready var login_identifier_input: LineEdit = %LoginIdentifierInput @onready var login_password_input: LineEdit = %LoginPasswordInput @onready var login_button: Button = %LoginButton +@onready var guest_button: Button = %GuestButton @onready var show_register_button: Button = %ShowRegisterButton @onready var register_username_input: LineEdit = %RegisterUsernameInput +@onready var register_invitation_code_input: LineEdit = %RegisterInvitationCodeInput @onready var register_email_input: LineEdit = %RegisterEmailInput @onready var send_register_code_button: Button = %SendRegisterCodeButton @onready var register_verification_code_input: LineEdit = %RegisterVerificationCodeInput @@ -96,8 +98,6 @@ var _brand_font: SystemFont var _choice_font: Font var _resuming_cached_session: bool = false 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_job_id: String = "" var _skin_generation_poll_elapsed: float = 0.0 @@ -110,6 +110,7 @@ var _registration_official_skins: Array = [] var _registration_official_skin_index: int = 0 var _registration_avatar_source_path: String = "" var _registration_skin_source_path: String = "" +var _registration_avatar_controls_visible: bool = false var _workshop_source_image_path: String = "" var _awaiting_registration_skin_generation: bool = false var _awaiting_registration_skin_choice: bool = false @@ -210,7 +211,7 @@ func _build_login_form(parent: Control) -> void: box.offset_right = -12 parent.add_child(box) - var title := _brand_label("TitleLabel", "WhaleTown V2") + var title := _brand_label("TitleLabel", "WhaleTown") title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER box.add_child(title) box.add_child(HSeparator.new()) @@ -247,6 +248,9 @@ func _build_login_form(parent: Control) -> void: var loginButton := _primary_button("LoginButton", "进入小镇", 25, Vector2(396, 58)) loginButton.unique_name_in_owner = true box.add_child(loginButton) + var guestButton := _secondary_button("GuestButton", "游客参观", 17, Vector2(396, 40)) + guestButton.unique_name_in_owner = true + box.add_child(guestButton) var links := HBoxContainer.new() links.name = "BottomLinks" @@ -268,7 +272,7 @@ func _build_register_form(parent: Control) -> void: var box := VBoxContainer.new() box.name = "RegisterVBox" box.add_theme_constant_override("separation", 3) - _place(box, Rect2(8, 40, 422, 448)) + _place(box, Rect2(8, 20, 422, 478)) content.add_child(box) var title := _label("RegisterTitleLabel", "注册新居民", 22, TEXT_COLOR, HORIZONTAL_ALIGNMENT_CENTER) @@ -281,6 +285,12 @@ func _build_register_form(parent: Control) -> void: usernameInput.unique_name_in_owner = true box.add_child(usernameInput) + box.add_child(_register_form_label("邀请码")) + var invitationCodeInput := _register_line_edit("RegisterInvitationCodeInput", "输入邀请码", false) + invitationCodeInput.unique_name_in_owner = true + invitationCodeInput.max_length = 30 + box.add_child(invitationCodeInput) + box.add_child(_register_form_label("邮箱")) var emailRow := HBoxContainer.new() emailRow.name = "RegisterEmailRow" @@ -312,7 +322,7 @@ func _build_register_form(parent: Control) -> void: var buttonSpacer := Control.new() buttonSpacer.name = "RegisterButtonSpacer" - buttonSpacer.custom_minimum_size = Vector2(0, 12) + buttonSpacer.custom_minimum_size = Vector2(0, 4) box.add_child(buttonSpacer) var buttons := HBoxContainer.new() @@ -692,11 +702,12 @@ func _ready() -> void: _scene_manager = get_node_or_null("/root/SceneManager") _appearance_manager = get_node_or_null("/root/AppearanceManager") - _setup_skin_generation_requests() _connect_signals() _refresh_appearance_ui() _show_login() _notify_web_shell_ready() + if _scene_manager != null and _scene_manager.has_method("preload_scene_pack"): + _scene_manager.call("preload_scene_pack", "square") if _auth_manager != null and bool(_auth_manager.call("is_authenticated")) and _should_auto_resume_cached_session(): _resume_cached_session() @@ -711,6 +722,7 @@ func _ready() -> void: func _connect_signals() -> void: login_button.pressed.connect(_on_login_pressed) + guest_button.pressed.connect(_on_guest_pressed) show_register_button.pressed.connect(_show_register) send_register_code_button.pressed.connect(_on_send_register_code_pressed) register_button.pressed.connect(_on_register_pressed) @@ -757,19 +769,6 @@ func _complete_browser_bootstrap(kind: String) -> void: return _on_login_succeeded(_auth_manager.call("get_current_user")) -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: if not _skin_generation_active or _skin_generation_job_id.is_empty(): return @@ -779,10 +778,6 @@ func _process(delta: float) -> void: _poll_skin_generation_job() 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_job_id = "" @@ -936,6 +931,7 @@ func _set_registration_character_controls_visible(visible: bool) -> void: _refresh_appearance_ui() func _set_registration_avatar_controls_visible(visible: bool) -> void: + _registration_avatar_controls_visible = visible if not is_instance_valid(registration_character_area): return var controlNames := [ @@ -968,6 +964,7 @@ func _refresh_character_preview() -> void: character_sprite.modulate = Color.WHITE character_sprite.frame = 0 character_sprite.scale = _sprite_scale_for_height(character_sprite.texture, character_sprite.vframes, MAIN_PREVIEW_HEIGHT) + character_sprite.position = Vector2(50, 70) + _sprite_visual_center_offset(character_sprite.texture, character_sprite.hframes, character_sprite.vframes, character_sprite.frame, character_sprite.scale) _clear_workshop_preview() return if _appearance_manager.has_method("apply_skin_to_sprite"): @@ -976,6 +973,7 @@ func _refresh_character_preview() -> void: _appearance_manager.call("apply_skin_to_sprite", character_sprite, previewSkinId) character_sprite.frame = 0 character_sprite.scale = _sprite_scale_for_height(character_sprite.texture, character_sprite.vframes, MAIN_PREVIEW_HEIGHT) + character_sprite.position = Vector2(50, 70) + _sprite_visual_center_offset(character_sprite.texture, character_sprite.hframes, character_sprite.vframes, character_sprite.frame, character_sprite.scale) _clear_workshop_preview() func _clear_workshop_preview() -> void: @@ -1006,6 +1004,7 @@ func _show_workshop_generated_preview_from_image(image: Image) -> void: workshop_preview_sprite.vframes = 4 workshop_preview_sprite.frame = 0 workshop_preview_sprite.scale = _sprite_scale_for_height(texture, 4, 156.0) + workshop_preview_sprite.position = Vector2(75, 105) + _sprite_visual_center_offset(texture, 8, 4, workshop_preview_sprite.frame, workshop_preview_sprite.scale) workshop_preview_sprite.visible = true func _show_workshop_source_preview(path: String) -> void: @@ -1102,6 +1101,7 @@ func _create_skin_button(skin: Dictionary, selectedSkinId: String) -> Button: preview.frame = 0 preview.position = Vector2(90, 99) preview.scale = _sprite_scale_for_frame_bounds(preview.texture, preview.hframes, preview.vframes, Vector2(136, SKIN_THUMB_HEIGHT)) + preview.position += _sprite_visual_center_offset(preview.texture, preview.hframes, preview.vframes, preview.frame, preview.scale) preview.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR button.add_child(preview) @@ -1170,6 +1170,9 @@ func _skin_texture(skin: Dictionary) -> Texture2D: func _refresh_avatar_preview() -> void: avatar_preview.custom_minimum_size = Vector2(34, 34) + if not _registration_avatar_controls_visible: + avatar_preview.hide() + return if _registration_avatar_texture == null: _clear_registration_avatar_preview() return @@ -1202,7 +1205,7 @@ func _show_registration_avatar_preview(texture: Texture2D) -> void: return avatar_preview.show() avatar_preview.clip_contents = true - avatar_preview.add_theme_stylebox_override("panel", _panel_style(Color.WHITE, 18)) + avatar_preview.add_theme_stylebox_override("panel", _panel_style(Color.WHITE, 22, Color(0.45, 0.66, 0.84, 0.55), 1)) if is_instance_valid(avatar_label): avatar_label.hide() var textureRect := _get_or_create_registration_avatar_texture_rect() @@ -1218,11 +1221,19 @@ func _get_or_create_registration_avatar_texture_rect() -> TextureRect: textureRect.mouse_filter = Control.MOUSE_FILTER_IGNORE textureRect.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR textureRect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - textureRect.stretch_mode = TextureRect.STRETCH_SCALE + textureRect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED + textureRect.material = _create_avatar_mask_material() textureRect.set_anchors_preset(Control.PRESET_FULL_RECT) avatar_preview.add_child(textureRect) return textureRect +func _create_avatar_mask_material() -> ShaderMaterial: + var material := ShaderMaterial.new() + material.shader = AVATAR_MASK_SHADER + material.set_shader_parameter("corner_radius", 0.22) + material.set_shader_parameter("edge_feather", 0.008) + return material + func _load_registration_avatar_texture(path: String) -> Texture2D: var cropped := _load_registration_avatar_image(path) if cropped == null: @@ -1432,23 +1443,19 @@ func _on_generate_skin_pressed() -> void: "source_image_base64": _image_to_png_base64(sourceImage), "source_mime_type": "image/png", } - var err := _skin_generation_create_request.request( - "%s%s" % [NetworkConfig.get_api_base_url(), SKIN_GENERATION_CREATE_ENDPOINT], - _auth_json_headers(), - HTTPClient.METHOD_POST, - JSON.stringify(payload) - ) - if err != OK: + var apiClient := get_node_or_null("/root/ApiClient") + if apiClient == null or not apiClient.has_method("post_json"): _skin_generation_active = false _set_skin_generation_controls_enabled(true) - _set_workshop_generation_status("角色生成请求发送失败:%s" % error_string(err)) + _set_workshop_generation_status("角色生成服务未加载") + return + apiClient.call("post_json", SKIN_GENERATION_CREATE_ENDPOINT, payload, _on_skin_generation_create_completed, true) -func _on_skin_generation_create_completed(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void: - var parsed := _parse_skin_generation_response(result, responseCode, body) - if not bool(parsed.get("ok", false)): +func _on_skin_generation_create_completed(success: bool, response: Dictionary, errorInfo: Dictionary) -> void: + if not success: _skin_generation_active = false _set_skin_generation_controls_enabled(true) - var errorMessage := str(parsed.get("message", "角色生成任务创建失败")) + var errorMessage := str(errorInfo.get("message", "角色生成任务创建失败")) if errorMessage.contains("已经使用过注册角色生成机会") or errorMessage.contains("没有可用的注册角色生成机会"): _awaiting_registration_skin_generation = false _set_workshop_generation_status("该账号已完成注册角色生成,正在进入小镇...") @@ -1457,7 +1464,13 @@ func _on_skin_generation_create_completed(result: int, responseCode: int, _heade _set_workshop_generation_status(errorMessage) return - var data: Dictionary = parsed.get("data", {}) + var dataVariant: Variant = response.get("data", {}) + if not (dataVariant is Dictionary): + _skin_generation_active = false + _set_skin_generation_controls_enabled(true) + _set_workshop_generation_status("服务器返回的生成任务格式错误") + return + var data: Dictionary = dataVariant _skin_generation_job_id = str(data.get("job_id", "")).strip_edges() if _skin_generation_job_id.is_empty(): _skin_generation_active = false @@ -1473,26 +1486,30 @@ func _poll_skin_generation_job() -> void: _skin_generation_poll_in_flight = true var endpoint := SKIN_GENERATION_POLL_ENDPOINT_TEMPLATE % _skin_generation_job_id - var err := _skin_generation_poll_request.request( - "%s%s" % [NetworkConfig.get_api_base_url(), endpoint], - _auth_json_headers(), - HTTPClient.METHOD_GET - ) - if err != OK: + var apiClient := get_node_or_null("/root/ApiClient") + if apiClient == null or not apiClient.has_method("get_json"): _skin_generation_poll_in_flight = false - _set_workshop_generation_status("查询生成状态失败:%s" % error_string(err)) + _set_workshop_generation_status("角色生成服务未加载") + return + apiClient.call("get_json", endpoint, _on_skin_generation_poll_completed, true) -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, errorInfo: Dictionary) -> void: _skin_generation_poll_in_flight = false - var parsed := _parse_skin_generation_response(result, responseCode, body) - if not bool(parsed.get("ok", false)): + if not success: + var responseCode := int(errorInfo.get("response_code", 0)) if responseCode == 401 or responseCode == 403 or responseCode == 404: _skin_generation_active = false _set_skin_generation_controls_enabled(true) - _set_workshop_generation_status(str(parsed.get("message", "查询生成状态失败"))) + _set_workshop_generation_status(str(errorInfo.get("message", "查询生成状态失败"))) return - var data: Dictionary = parsed.get("data", {}) + var dataVariant: Variant = response.get("data", {}) + if not (dataVariant is Dictionary): + _skin_generation_active = false + _set_skin_generation_controls_enabled(true) + _set_workshop_generation_status("服务器返回的生成状态格式错误") + return + var data: Dictionary = dataVariant var status := str(data.get("status", "")).strip_edges() var message := str(data.get("message", "")).strip_edges() if not message.is_empty(): @@ -1578,74 +1595,6 @@ func _set_skin_generation_controls_enabled(enabled: bool) -> void: if is_instance_valid(workshop_generate_button): 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: if _awaiting_registration_skin_generation: _awaiting_registration_skin_generation = false @@ -1953,7 +1902,7 @@ func _on_send_register_code_pressed() -> void: _is_sending_register_code = true send_register_code_button.disabled = true status_label.text = "正在发送邮箱验证码..." - _auth_manager.call("send_email_verification", register_email_input.text) + _auth_manager.call("send_email_verification", register_email_input.text, register_invitation_code_input.text) func _on_register_pressed() -> void: if _is_submitting: @@ -1976,7 +1925,8 @@ func _on_register_pressed() -> void: "", register_email_input.text, register_verification_code_input.text, - _registration_backend_initial_skin_id() + _registration_backend_initial_skin_id(), + register_invitation_code_input.text ) func _on_login_succeeded(_user: Dictionary) -> void: @@ -1987,6 +1937,17 @@ func _on_login_succeeded(_user: Dictionary) -> void: _auth_manager.call("fetch_profile") _enter_square() +func _on_guest_pressed() -> void: + if _is_submitting: + return + var chatManager := get_node_or_null("/root/ChatManager") + if chatManager == null or not chatManager.has_method("start_guest_session"): + status_label.text = "旁观服务未加载" + return + _set_submitting(true, "正在进入游客参观模式...") + chatManager.call("start_guest_session") + SceneManager.change_scene("square") + func _on_login_failed(message: String) -> void: _set_submitting(false, message) login_password_input.grab_focus() @@ -2192,6 +2153,7 @@ func _set_submitting(is_submitting: bool, message: String) -> void: _is_submitting = is_submitting status_label.text = message login_button.disabled = is_submitting + guest_button.disabled = is_submitting register_button.disabled = is_submitting send_register_code_button.disabled = is_submitting or _is_sending_register_code show_register_button.disabled = is_submitting @@ -2453,3 +2415,35 @@ func _sprite_scale_for_frame_bounds(texture: Texture2D, hframes: int, vframes: i return Vector2.ONE var scale: float = minf(bounds.x / frameSize.x, bounds.y / frameSize.y) return Vector2(scale, scale) + +func _sprite_visual_center_offset(texture: Texture2D, hframes: int, vframes: int, frame: int, scale: Vector2) -> Vector2: + if texture == null: + return Vector2.ZERO + var image := texture.get_image() + if image == null: + return Vector2.ZERO + var safeHframes := maxi(1, hframes) + var safeVframes := maxi(1, vframes) + var frameWidth := image.get_width() / safeHframes + var frameHeight := image.get_height() / safeVframes + if frameWidth <= 0 or frameHeight <= 0: + return Vector2.ZERO + var safeFrame := clampi(frame, 0, safeHframes * safeVframes - 1) + var frameOrigin := Vector2i((safeFrame % safeHframes) * frameWidth, (safeFrame / safeHframes) * frameHeight) + var minX := frameWidth + var minY := frameHeight + var maxX := -1 + var maxY := -1 + for y in range(frameHeight): + for x in range(frameWidth): + if image.get_pixel(frameOrigin.x + x, frameOrigin.y + y).a <= 0.08: + continue + minX = mini(minX, x) + minY = mini(minY, y) + maxX = maxi(maxX, x) + maxY = maxi(maxY, y) + if maxX < minX or maxY < minY: + return Vector2.ZERO + var visualCenter := Vector2((float(minX) + float(maxX) + 1.0) * 0.5, (float(minY) + float(maxY) + 1.0) * 0.5) + var frameCenter := Vector2(float(frameWidth), float(frameHeight)) * 0.5 + return (frameCenter - visualCenter) * scale diff --git a/scenes/ui/BubbleSendButton.gd b/scenes/ui/BubbleSendButton.gd index 57bdb1f..0b0e00d 100644 --- a/scenes/ui/BubbleSendButton.gd +++ b/scenes/ui/BubbleSendButton.gd @@ -51,13 +51,13 @@ func _draw_polyline(points: Array[Vector2], color: Color, width: float) -> void: var packed: PackedVector2Array = [] for point in points: packed.append(_p(point)) - draw_polyline(packed, color, width, true) + draw_polyline(packed, color, width * _iconScale, false) func _draw_line(from: Vector2, to: Vector2, color: Color, width: float) -> void: - draw_line(_p(from), _p(to), color, width, true) + draw_line(_p(from), _p(to), color, width * _iconScale, false) func _draw_arc(center: Vector2, radius: float, startAngle: float, endAngle: float, pointCount: int, color: Color, width: float) -> void: - draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, color, width, true) + draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, color, width * _iconScale, false) func _p(point: Vector2) -> Vector2: return _iconOffset + point * _iconScale diff --git a/scenes/ui/CafeCompanionRecruitmentPanel.gd b/scenes/ui/CafeCompanionRecruitmentPanel.gd index 2896cb4..64ccc39 100644 --- a/scenes/ui/CafeCompanionRecruitmentPanel.gd +++ b/scenes/ui/CafeCompanionRecruitmentPanel.gd @@ -131,7 +131,9 @@ func _render_service_points() -> void: var index := servicePointOption.get_item_count() servicePointOption.add_item(_format_service_point_option(point)) servicePointOption.set_item_metadata(index, pointId) - if selectedIndex < 0 and not _point_has_companion(point): + var isOccupied := _point_has_companion(point) + servicePointOption.set_item_disabled(index, isOccupied) + if selectedIndex < 0 and not isOccupied: selectedIndex = index if servicePointOption.get_item_count() <= 0: @@ -139,7 +141,14 @@ func _render_service_points() -> void: submitButton.disabled = true return - servicePointOption.select(selectedIndex if selectedIndex >= 0 else 0) + if selectedIndex < 0: + servicePointOption.select(-1) + submitButton.disabled = true + fetchModelsButton.disabled = _isSubmitting + _set_status("当前陪伴位均已被占用", true) + return + + servicePointOption.select(selectedIndex) submitButton.disabled = _isSubmitting fetchModelsButton.disabled = _isSubmitting _set_status("填写人设和代理配置后可登记", false) @@ -227,6 +236,10 @@ func _build_payload() -> Dictionary: if servicePointId.is_empty(): _set_status("请选择陪伴位", true) return {} + if not _is_service_point_available(servicePointId): + _set_status("该陪伴位已被占用,请选择其他空位", true) + _request_service_points() + return {} if personaName.is_empty(): _set_status("请填写人设名称", true) personaNameInput.grab_focus() @@ -293,6 +306,15 @@ func _selected_service_point_id() -> String: return "" return str(servicePointOption.get_item_metadata(selectedIndex)).strip_edges() +func _is_service_point_available(servicePointId: String) -> bool: + for pointVariant in _servicePoints: + if not (pointVariant is Dictionary): + continue + var point: Dictionary = pointVariant + if str(point.get("id", "")).strip_edges() == servicePointId: + return not _point_has_companion(point) + return false + func _setup_protocol_options() -> void: protocolOption.clear() protocolOption.add_item("OpenAI", 0) diff --git a/scenes/ui/ChatBubble.gd b/scenes/ui/ChatBubble.gd index 9b1ee58..b802e40 100644 --- a/scenes/ui/ChatBubble.gd +++ b/scenes/ui/ChatBubble.gd @@ -22,7 +22,12 @@ func _process(_delta: float) -> void: _update_position() -func set_text(text: String, targetNode: Node2D = null, targetOffset: Vector2 = TARGET_OFFSET) -> void: +func set_text( + text: String, + targetNode: Node2D = null, + targetOffset: Vector2 = TARGET_OFFSET, + duration: float = DEFAULT_DURATION, +) -> void: _originalText = text _targetNode = targetNode _targetOffset = targetOffset @@ -31,7 +36,7 @@ func set_text(text: String, targetNode: Node2D = null, targetOffset: Vector2 = T if _targetNode != null: _update_position() - await get_tree().create_timer(DEFAULT_DURATION).timeout + await get_tree().create_timer(maxf(0.1, duration)).timeout queue_free() func _update_size() -> void: diff --git a/scenes/ui/ChatUI.gd b/scenes/ui/ChatUI.gd index 1f5b47e..12c5ac3 100644 --- a/scenes/ui/ChatUI.gd +++ b/scenes/ui/ChatUI.gd @@ -56,6 +56,9 @@ extends Control @onready var friends_list_surface: Control = %FriendsListSurface @onready var add_friend_row: HBoxContainer = %AddFriendRow @onready var add_friend_button: Button = %AddFriendButton +@onready var tabs: HBoxContainer = $ChatPanel/PanelMargin/ContentVBox/Tabs +@onready var panel_margin: MarginContainer = $ChatPanel/PanelMargin +@onready var input_row: HBoxContainer = $ChatPanel/PanelMargin/ContentVBox/InputRow # ============================================================================ # 预加载资源 @@ -80,6 +83,9 @@ const TAB_WHISPER: String = "whisper" const TAB_FRIENDS: String = "friends" const MAX_DISPLAYED_MESSAGES: int = 100 const MOVEMENT_ACTIONS: Array[String] = ["move_left", "move_right", "move_up", "move_down"] +const SEND_ICON := preload("res://assets/ui/world_bulletin/world_bulletin_send_v2_128.png") +const STATIC_NPC_DIALOGUE_PREFIX: String = "static_npc_dialogue:" +const DEFAULT_WHISPER_TAB_TEXT: String = "悄悄话" # ============================================================================ # 成员变量 @@ -106,6 +112,9 @@ var _current_tab: String = TAB_WORLD # 悄悄话目标(靠近玩家按 E 后设置) var _whisper_target_user_id: String = "" var _whisper_target_username: String = "" +var _npc_target_id: String = "" +var _npc_target_name: String = "" +var _npc_session_id: String = "" # 好友私聊目标(好友列表接入后复用同一私聊协议) var _friend_target_user_id: String = "" @@ -118,6 +127,12 @@ var _friends_status_message: String = "" var _messages: Array[Dictionary] = [] var _send_failure_handled_by_ui: bool = false +var _npc_thinking_row: Control +var _npc_thinking_timer: Timer +var _npc_dialogue_mode: bool = false +var _npc_dialogue_read_only: bool = false +var _default_chat_panel_style: StyleBox +var _default_input_shell_style: StyleBox # ============================================================================ # 生命周期方法 @@ -126,12 +141,17 @@ var _send_failure_handled_by_ui: bool = false # 准备就绪 func _ready() -> void: _configure_mouse_focus() + _configure_send_icon() + _capture_default_dialogue_styles() + if not get_viewport().size_changed.is_connected(_on_viewport_size_changed): + get_viewport().size_changed.connect(_on_viewport_size_changed) # 初始隐藏聊天框 hide_chat(true) # 创建隐藏计时器 _create_hide_timer() + _create_npc_thinking_timer() # 订阅事件(Call Down via EventSystem) _subscribe_to_events() @@ -144,6 +164,134 @@ func _ready() -> void: _update_tab_visuals() _update_bubble_send_button_visibility() +func _configure_send_icon() -> void: + if not is_instance_valid(send_button): + return + send_button.text = "" + send_button.icon = SEND_ICON + send_button.expand_icon = true + send_button.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR + +func _capture_default_dialogue_styles() -> void: + if is_instance_valid(chat_panel): + _default_chat_panel_style = chat_panel.get_theme_stylebox("panel").duplicate() as StyleBox + if is_instance_valid(input_shell): + _default_input_shell_style = input_shell.get_theme_stylebox("panel").duplicate() as StyleBox + +func _set_npc_dialogue_mode(enabled: bool, readOnly: bool = false) -> void: + _npc_dialogue_mode = enabled + _npc_dialogue_read_only = enabled and readOnly + + var worldTab := popular_tab_button.get_parent() as Control if is_instance_valid(popular_tab_button) else null + var whisperTab := recent_tab_button.get_parent() as Control if is_instance_valid(recent_tab_button) else null + var friendsTab := friends_tab_button.get_parent() as Control if is_instance_valid(friends_tab_button) else null + if is_instance_valid(worldTab): + worldTab.visible = not enabled + if is_instance_valid(friendsTab): + friendsTab.visible = not enabled + if is_instance_valid(whisperTab): + whisperTab.visible = true + if is_instance_valid(recent_tab_button): + recent_tab_button.text = _npc_target_name if enabled else DEFAULT_WHISPER_TAB_TEXT + recent_tab_button.alignment = HORIZONTAL_ALIGNMENT_LEFT if enabled else HORIZONTAL_ALIGNMENT_CENTER + recent_tab_button.mouse_filter = Control.MOUSE_FILTER_IGNORE if enabled else Control.MOUSE_FILTER_STOP + if is_instance_valid(input_row): + input_row.visible = not _npc_dialogue_read_only + + if is_instance_valid(chat_panel): + var panelStyle := _create_npc_dialogue_style() if enabled else _default_chat_panel_style + if panelStyle != null: + chat_panel.add_theme_stylebox_override("panel", panelStyle) + if is_instance_valid(input_shell): + var inputStyle := _create_npc_dialogue_input_style() if enabled else _default_input_shell_style + if inputStyle != null: + input_shell.add_theme_stylebox_override("panel", inputStyle) + if is_instance_valid(panel_margin): + var horizontalMargin := 24 if enabled else 20 + var verticalMargin := 18 + panel_margin.add_theme_constant_override("margin_left", horizontalMargin) + panel_margin.add_theme_constant_override("margin_top", verticalMargin) + panel_margin.add_theme_constant_override("margin_right", horizontalMargin) + panel_margin.add_theme_constant_override("margin_bottom", verticalMargin) + + _apply_chat_panel_layout() + _update_tab_visuals() + if is_instance_valid(message_list): + _rerender_messages() + +func _clear_npc_dialogue_target() -> void: + _npc_target_id = "" + _npc_target_name = "" + _npc_session_id = "" + _set_npc_dialogue_mode(false, false) + +func _apply_chat_panel_layout() -> void: + if not is_instance_valid(chat_panel): + return + if not _npc_dialogue_mode: + chat_panel.anchor_left = 0.024 + chat_panel.anchor_top = 1.0 + chat_panel.anchor_right = 0.024 + chat_panel.anchor_bottom = 1.0 + chat_panel.offset_left = 0.0 + chat_panel.offset_top = -448.0 + chat_panel.offset_right = 548.0 + chat_panel.offset_bottom = -32.0 + return + + var viewportSize := get_viewport_rect().size + var dialogWidth := clampf(viewportSize.x * 0.90, 320.0, 760.0) + var minimumHeight := 200.0 if _npc_dialogue_read_only else 250.0 + var maximumHeight := 250.0 if _npc_dialogue_read_only else 340.0 + var dialogHeight := clampf(viewportSize.y * 0.42, minimumHeight, maximumHeight) + var bottomMargin := clampf(viewportSize.y * 0.04, 18.0, 34.0) + chat_panel.anchor_left = 0.5 + chat_panel.anchor_top = 1.0 + chat_panel.anchor_right = 0.5 + chat_panel.anchor_bottom = 1.0 + chat_panel.offset_left = -dialogWidth * 0.5 + chat_panel.offset_top = -bottomMargin - dialogHeight + chat_panel.offset_right = dialogWidth * 0.5 + chat_panel.offset_bottom = -bottomMargin + +func _on_viewport_size_changed() -> void: + _apply_chat_panel_layout() + +func _create_npc_dialogue_style() -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = Color(1.0, 0.985, 0.91, 0.985) + style.border_color = Color(0.055, 0.22, 0.235, 1.0) + style.border_width_left = 4 + style.border_width_top = 4 + style.border_width_right = 4 + style.border_width_bottom = 4 + style.corner_radius_top_left = 5 + style.corner_radius_top_right = 5 + style.corner_radius_bottom_left = 5 + style.corner_radius_bottom_right = 5 + style.shadow_color = Color(0.02, 0.06, 0.07, 0.3) + style.shadow_size = 8 + style.shadow_offset = Vector2(0, 5) + return style + +func _create_npc_dialogue_input_style() -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = Color(1.0, 1.0, 0.98, 1.0) + style.border_color = Color(0.22, 0.42, 0.42, 0.82) + style.border_width_left = 2 + style.border_width_top = 2 + style.border_width_right = 2 + style.border_width_bottom = 2 + style.corner_radius_top_left = 4 + style.corner_radius_top_right = 4 + style.corner_radius_bottom_left = 4 + style.corner_radius_bottom_right = 4 + style.content_margin_left = 14 + style.content_margin_top = 6 + style.content_margin_right = 14 + style.content_margin_bottom = 6 + return style + # 清理 func _exit_tree() -> void: # 取消事件订阅 @@ -155,6 +303,8 @@ func _exit_tree() -> void: eventSystem.call("disconnect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_login_success, self) eventSystem.call("disconnect_event", EventNames.CHAT_LOGIN_FAILED, _on_login_failed, self) eventSystem.call("disconnect_event", EventNames.CHAT_PRIVATE_TARGET_SELECTED, _on_private_target_selected, self) + eventSystem.call("disconnect_event", EventNames.NPC_SPOKE, _on_npc_spoke, self) + eventSystem.call("disconnect_event", EventNames.NPC_INTERACTION_ERROR, _on_npc_interaction_error, self) eventSystem.call("disconnect_event", EventNames.CHAT_FRIEND_SELECTED, _on_friend_selected, self) eventSystem.call("disconnect_event", EventNames.CHAT_FRIENDS_UPDATED, _on_friends_updated, self) eventSystem.call("disconnect_event", EventNames.SETTINGS_CHANGED, _on_settings_changed, self) @@ -162,6 +312,11 @@ func _exit_tree() -> void: # 清理计时器 if _hide_timer: _hide_timer.queue_free() + if _npc_thinking_timer: + _npc_thinking_timer.queue_free() + _hide_npc_thinking() + if get_viewport() != null and get_viewport().size_changed.is_connected(_on_viewport_size_changed): + get_viewport().size_changed.disconnect(_on_viewport_size_changed) if is_instance_valid(_transition_tween): _transition_tween.kill() @@ -192,6 +347,10 @@ func _input(event: InputEvent) -> void: var key_event := event as InputEventKey if not key_event.pressed or key_event.echo: return + if key_event.keycode == KEY_ESCAPE and _is_chat_visible: + hide_chat() + get_viewport().set_input_as_handled() + return # T 键用于唤起聊天(输入框聚焦时不拦截) if key_event.keycode == KEY_T and not chat_input.has_focus(): @@ -264,6 +423,9 @@ func _update_input_placeholder() -> void: if _current_tab == TAB_WHISPER and not _whisper_target_username.is_empty(): chat_input.placeholder_text = "对 %s 说点什么..." % _whisper_target_username return + if _current_tab == TAB_WHISPER and not _npc_target_name.is_empty(): + chat_input.placeholder_text = "对 %s 说点什么..." % _npc_target_name + return if _current_tab == TAB_FRIENDS and not _friend_target_username.is_empty(): chat_input.placeholder_text = "对 %s 说点什么..." % _friend_target_username @@ -338,6 +500,8 @@ func show_chat(immediate: bool = false) -> void: # 隐藏聊天框 func hide_chat(immediate: bool = false) -> void: + _end_npc_session() + _clear_npc_dialogue_target() _is_chat_visible = false _is_typing = false @@ -374,6 +538,13 @@ func _create_hide_timer() -> void: _hide_timer.timeout.connect(_on_hide_timeout) add_child(_hide_timer) +func _create_npc_thinking_timer() -> void: + _npc_thinking_timer = Timer.new() + _npc_thinking_timer.wait_time = 60.0 + _npc_thinking_timer.one_shot = true + _npc_thinking_timer.timeout.connect(_on_npc_thinking_timeout) + add_child(_npc_thinking_timer) + # 开始隐藏倒计时 func _start_hide_timer() -> void: if _is_typing: @@ -508,6 +679,17 @@ func _send_input_message(show_bubble: bool) -> void: # 清空输入框 chat_input.clear() + if not _npc_target_id.is_empty() and _current_tab == TAB_WHISPER: + _add_message_data({ + "from_user": _current_username, + "content": content, + "timestamp": Time.get_unix_time_from_system(), + "is_self": true, + "scope": "private", + "private_context": TAB_WHISPER, + "npc_id": _npc_target_id, + }) + _show_npc_thinking() # 发送后延迟重新聚焦,避免被 LineEdit 的提交事件在同一帧内抢走焦点 call_deferred("_focus_input_after_send") @@ -565,6 +747,8 @@ func _subscribe_to_events() -> void: # 订阅近身悄悄话目标选择事件 eventSystem.call("connect_event", EventNames.CHAT_PRIVATE_TARGET_SELECTED, _on_private_target_selected, self) + eventSystem.call("connect_event", EventNames.NPC_SPOKE, _on_npc_spoke, self) + eventSystem.call("connect_event", EventNames.NPC_INTERACTION_ERROR, _on_npc_interaction_error, self) # 订阅右下角好友列表的好友选择事件 eventSystem.call("connect_event", EventNames.CHAT_FRIEND_SELECTED, _on_friend_selected, self) @@ -611,9 +795,83 @@ func _on_chat_error(data: Dictionary) -> void: if _current_tab == TAB_FRIENDS: _render_friend_conversation_header() return + if not _npc_target_id.is_empty() and _current_tab == TAB_WHISPER: + _hide_npc_thinking() if not message.strip_edges().is_empty(): _add_system_message(message) +func _on_npc_spoke(data: Dictionary) -> void: + var npc_id := str(data.get("npc_id", data.get("npcId", ""))).strip_edges() + if _npc_target_id.is_empty() or npc_id != _npc_target_id: + return + var response := str(data.get("response", "")).strip_edges() + if response.is_empty(): + return + _hide_npc_thinking() + var session_id := str(data.get("session_id", data.get("sessionId", ""))).strip_edges() + if not session_id.is_empty(): + _npc_session_id = session_id + _add_message_data({ + "from_user": str(data.get("npc_name", data.get("npcName", _npc_target_name))), + "content": response, + "timestamp": Time.get_unix_time_from_system(), + "is_self": false, + "scope": "private", + "private_context": TAB_WHISPER, + "npc_id": npc_id, + }) + +func _on_npc_interaction_error(data: Dictionary) -> void: + var npc_id := str(data.get("npc_id", data.get("npcId", ""))).strip_edges() + if not _npc_target_id.is_empty() and not npc_id.is_empty() and npc_id != _npc_target_id: + return + _hide_npc_thinking() + var message := str(data.get("message", "NPC暂时无法回应")).strip_edges() + if not message.is_empty(): + _add_system_message(message) + +func _show_npc_thinking() -> void: + _hide_npc_thinking() + if _npc_target_id.is_empty() or _current_tab != TAB_WHISPER: + return + if not is_instance_valid(message_list): + return + + var row := HBoxContainer.new() + row.name = "NpcThinkingRow" + row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.size_flags_vertical = Control.SIZE_SHRINK_BEGIN + row.mouse_filter = Control.MOUSE_FILTER_IGNORE + message_list.add_child(row) + + var message_node := chat_message_scene.instantiate() as Control + if message_node == null: + row.queue_free() + return + row.add_child(message_node) + if message_node.has_method("set_message"): + message_node.call("set_message", _npc_target_name, "正在思考...", Time.get_unix_time_from_system(), false) + if message_node.has_method("set_dialogue_mode"): + message_node.call("set_dialogue_mode", _npc_dialogue_mode) + message_node.modulate = Color(1.0, 1.0, 1.0, 0.78) + _npc_thinking_row = row + if is_instance_valid(_npc_thinking_timer): + _npc_thinking_timer.start() + call_deferred("_scroll_to_bottom") + +func _hide_npc_thinking() -> void: + if is_instance_valid(_npc_thinking_timer): + _npc_thinking_timer.stop() + if is_instance_valid(_npc_thinking_row): + _npc_thinking_row.queue_free() + _npc_thinking_row = null + +func _on_npc_thinking_timeout() -> void: + if not is_instance_valid(_npc_thinking_row): + return + _hide_npc_thinking() + _add_system_message("NPC暂时没有回应,请稍后再试") + # 处理连接状态变化 func _on_connection_state_changed(_data: Dictionary) -> void: # 连接状态变化处理(当前不更新UI) @@ -699,6 +957,8 @@ func _send_current_tab_message(chatManager: Node, content: String, show_bubble: TAB_WORLD: return bool(chatManager.call("send_chat_message", content, "global", show_bubble)) TAB_WHISPER: + if not _npc_target_id.is_empty(): + return bool(chatManager.call("interact_with_world_npc", _npc_target_id, content, _npc_session_id)) if _whisper_target_user_id.is_empty(): _add_system_message("请靠近玩家按 E 发起悄悄话") _send_failure_handled_by_ui = true @@ -734,6 +994,8 @@ func start_whisper(user_id: String, username: String = "") -> void: if normalized_user_id.is_empty(): return + _end_npc_session() + _clear_npc_dialogue_target() _whisper_target_user_id = normalized_user_id _whisper_target_username = username.strip_edges() if _whisper_target_username.is_empty(): @@ -744,6 +1006,66 @@ func start_whisper(user_id: String, username: String = "") -> void: show_chat(true) call_deferred("_focus_input_after_send") +func start_npc_whisper(npc_id: String, npc_name: String = "NPC", greeting: String = "") -> void: + var normalized_id := npc_id.strip_edges() + if normalized_id.is_empty(): + return + _end_npc_session() + _clear_npc_dialogue_target() + _hide_npc_thinking() + _npc_target_id = normalized_id + _npc_target_name = npc_name.strip_edges() if not npc_name.strip_edges().is_empty() else "NPC" + _npc_session_id = "" + _whisper_target_user_id = "" + _whisper_target_username = "" + select_tab(TAB_WHISPER) + _set_npc_dialogue_mode(true, false) + show_chat(true) + if not greeting.strip_edges().is_empty(): + _add_message_data({ + "from_user": _npc_target_name, + "content": greeting.strip_edges(), + "timestamp": Time.get_unix_time_from_system(), + "is_self": false, + "scope": "private", + "private_context": TAB_WHISPER, + "npc_id": _npc_target_id, + }) + call_deferred("_focus_input_after_send") + +func show_npc_dialogue(npc_name: String, text: String) -> void: + var normalizedText := text.strip_edges() + if normalizedText.is_empty(): + return + _end_npc_session() + _clear_npc_dialogue_target() + _hide_npc_thinking() + _npc_target_name = npc_name.strip_edges() if not npc_name.strip_edges().is_empty() else "NPC" + _npc_target_id = "%s%s:%d" % [STATIC_NPC_DIALOGUE_PREFIX, _npc_target_name, Time.get_ticks_msec()] + _npc_session_id = "" + _whisper_target_user_id = "" + _whisper_target_username = "" + select_tab(TAB_WHISPER) + _set_npc_dialogue_mode(true, true) + show_chat(true) + _add_message_data({ + "from_user": _npc_target_name, + "content": normalizedText, + "timestamp": Time.get_unix_time_from_system(), + "is_self": false, + "scope": "private", + "private_context": TAB_WHISPER, + "npc_id": _npc_target_id, + }) + +func _end_npc_session() -> void: + if _npc_target_id.is_empty() or _npc_session_id.is_empty() or _npc_target_id.begins_with(STATIC_NPC_DIALOGUE_PREFIX): + return + var chat_manager := _get_chat_manager() + if chat_manager != null and chat_manager.has_method("end_world_npc_session"): + chat_manager.call("end_world_npc_session", _npc_target_id, _npc_session_id) + _npc_session_id = "" + func add_whisper_target_as_friend() -> bool: if _whisper_target_user_id.is_empty(): _add_system_message("请先靠近玩家按 F") @@ -780,6 +1102,8 @@ func select_friend_private_target(user_id: String, username: String = "") -> voi if normalized_user_id.is_empty(): return + _end_npc_session() + _clear_npc_dialogue_target() _friend_target_user_id = normalized_user_id _friend_target_username = username.strip_edges() if _friend_target_username.is_empty(): @@ -934,15 +1258,23 @@ func _add_message_data(message: Dictionary) -> void: _render_message(message) func _render_message(message: Dictionary) -> void: - # 如果聊天框隐藏,自动显示 + # 世界频道消息进入公告 HUD,不自动抢占地图视野; + # 私聊、NPC 回复和用户主动发送的消息仍可唤起聊天面板。 if not _is_chat_visible: + var message_scope := str(message.get("scope", "global")).strip_edges().to_lower() + var is_private := bool(message.get("is_private", false)) or message_scope == "private" + var is_npc := not str(message.get("npc_id", "")).strip_edges().is_empty() + # 系统存在/欢迎消息也属于世界公告,不应自动打开旧聊天窗口。 + # 只有私聊和 NPC 会话需要在收到回复时主动唤起聊天 UI。 + if not is_private and not is_npc: + return show_chat() # 每条消息用一行容器包起来,方便左右对齐且不挤在一起 var row := HBoxContainer.new() row.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.size_flags_vertical = Control.SIZE_SHRINK_BEGIN - row.alignment = BoxContainer.ALIGNMENT_END if bool(message.get("is_self", false)) else BoxContainer.ALIGNMENT_BEGIN + row.alignment = BoxContainer.ALIGNMENT_BEGIN if _npc_dialogue_mode else (BoxContainer.ALIGNMENT_END if bool(message.get("is_self", false)) else BoxContainer.ALIGNMENT_BEGIN) # 创建消息节点 var message_node: Control = chat_message_scene.instantiate() as Control @@ -962,6 +1294,8 @@ func _render_message(message: Dictionary) -> void: float(message.get("timestamp", 0.0)), bool(message.get("is_self", false)) ) + if message_node.has_method("set_dialogue_mode"): + message_node.call("set_dialogue_mode", _npc_dialogue_mode) # 自动滚动到底部 call_deferred("_scroll_to_bottom") @@ -993,6 +1327,9 @@ func _message_matches_current_tab(message: Dictionary) -> bool: return true func _private_message_matches_current_tab(message: Dictionary) -> bool: + var npc_id := str(message.get("npc_id", message.get("npcId", ""))).strip_edges() + if _current_tab == TAB_WHISPER and not _npc_target_id.is_empty(): + return npc_id == _npc_target_id var scope := str(message.get("scope", "local")).strip_edges().to_lower() var is_private := bool(message.get("is_private", false)) or scope == "private" if not is_private: diff --git a/scenes/ui/HudShortcutIcon.gd b/scenes/ui/HudShortcutIcon.gd index fb589a6..7dd79cb 100644 --- a/scenes/ui/HudShortcutIcon.gd +++ b/scenes/ui/HudShortcutIcon.gd @@ -9,8 +9,8 @@ extends Control const LINE_COLOR: Color = Color(0.592157, 0.72549, 0.835294, 0.82) const DOT_COLOR: Color = Color(0.941176, 0.54902, 0.54902, 0.86) const DOT_BORDER_COLOR: Color = Color(1.0, 1.0, 1.0, 0.95) -const LINE_WIDTH: float = 0.85 -const DETAIL_WIDTH: float = 0.75 +const LINE_WIDTH: float = 1.15 +const DETAIL_WIDTH: float = 1.0 const BASE_SIZE: float = 27.0 var iconName: String = "map" @@ -127,17 +127,17 @@ func _draw_polyline(points: Array[Vector2], width: float = LINE_WIDTH) -> void: var packed: PackedVector2Array = [] for point in points: packed.append(_p(point)) - draw_polyline(packed, LINE_COLOR, width, true) + draw_polyline(packed, LINE_COLOR, width * _iconScale, false) func _draw_red_dot(center: Vector2) -> void: - draw_circle(_p(center), 2.3 * _iconScale, DOT_BORDER_COLOR) - draw_circle(_p(center), 1.55 * _iconScale, DOT_COLOR) + draw_circle(_p(center), 2.3 * _iconScale, DOT_BORDER_COLOR, true, -1.0, false) + draw_circle(_p(center), 1.55 * _iconScale, DOT_COLOR, true, -1.0, false) func _draw_line(from: Vector2, to: Vector2, width: float) -> void: - draw_line(_p(from), _p(to), LINE_COLOR, width, true) + draw_line(_p(from), _p(to), LINE_COLOR, width * _iconScale, false) func _draw_arc(center: Vector2, radius: float, startAngle: float, endAngle: float, pointCount: int, width: float) -> void: - draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, LINE_COLOR, width, true) + draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, LINE_COLOR, width * _iconScale, false) func _p(point: Vector2) -> Vector2: return _iconOffset + point * _iconScale diff --git a/scenes/ui/WorldBulletinPanel.gd b/scenes/ui/WorldBulletinPanel.gd new file mode 100644 index 0000000..3c49d8a --- /dev/null +++ b/scenes/ui/WorldBulletinPanel.gd @@ -0,0 +1,532 @@ +extends Control + +## Lightweight public announcement HUD. +## This panel owns only the public feed; private conversations remain in ChatUI. + +const PANEL_WIDTH := 560.0 +const COLLAPSED_HEIGHT := 68.0 +const EXPANDED_HEIGHT := 492.0 +const EDGE_MARGIN := 26.0 +const TEXT_COLOR := Color("24476f") +const MUTED_COLOR := Color("7894b2") +const ACCENT_COLOR := Color("2d94ed") +const SURFACE_COLOR := Color(0.985, 0.995, 1.0, 0.97) +const LINE_COLOR := Color("d9e8f7") +const MAX_ITEMS := 24 + +const ICON_EMPTY := preload("res://assets/ui/world_bulletin/community/empty_whale.png") +const ICON_BROADCAST := preload("res://assets/ui/world_bulletin/community/world_bulletin_icon_hd_simple_tight.png") +const ICON_BANNER := preload("res://assets/ui/world_bulletin/community/town_banner.png") +const ICON_PIN := preload("res://assets/ui/world_bulletin/community/pinned_note.png") +const ICON_RECRUIT := preload("res://assets/ui/world_bulletin/community/recruit_whales.png") +const ICON_SEND_BUTTON := preload("res://assets/ui/world_bulletin/world_bulletin_send_button_final_192.png") + +var _panel: PanelContainer +var _surface_root: VBoxContainer +var _ticker_row: HBoxContainer +var _ticker: Button +var _expand_button: Button +var _content: VBoxContainer +var _header_title: Label +var _header_icon: TextureRect +var _header_banner: TextureRect +var _entries: VBoxContainer +var _scroll: ScrollContainer +var _composer: LineEdit +var _send_button: Button +var _expanded := false +var _announcements: Array[Dictionary] = [] +var _unread := 0 +var _last_preview := "暂无新的世界公告" +var _publish_pending := false + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + _build_ui() + _seed_preview_items() + _subscribe_to_events() + _set_expanded(true, true) + +func _exit_tree() -> void: + var event_system := get_node_or_null("/root/EventSystem") + if event_system != null: + event_system.call("disconnect_event", EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self) + event_system.call("disconnect_event", EventNames.CHAT_MESSAGE_SENT, _on_chat_message_sent, self) + event_system.call("disconnect_event", EventNames.CHAT_ERROR_OCCURRED, _on_chat_error, self) + +func _input(event: InputEvent) -> void: + if event is InputEventKey: + var key_event := event as InputEventKey + if key_event.pressed and not key_event.echo and key_event.keycode == KEY_B and not _composer.has_focus(): + _set_expanded(not _expanded) + get_viewport().set_input_as_handled() + +func _build_ui() -> void: + _panel = PanelContainer.new() + _panel.name = "WorldBulletinSurface" + _panel.mouse_filter = Control.MOUSE_FILTER_STOP + _panel.add_theme_stylebox_override("panel", _surface_style()) + add_child(_panel) + _panel.set_anchors_preset(Control.PRESET_BOTTOM_LEFT) + _panel.offset_left = EDGE_MARGIN + _panel.offset_top = -EDGE_MARGIN - COLLAPSED_HEIGHT + _panel.offset_right = EDGE_MARGIN + PANEL_WIDTH + _panel.offset_bottom = -EDGE_MARGIN + _panel.grow_horizontal = Control.GROW_DIRECTION_END + _panel.grow_vertical = Control.GROW_DIRECTION_BEGIN + _surface_root = VBoxContainer.new() + _surface_root.name = "SurfaceContent" + _surface_root.add_theme_constant_override("separation", 0) + _surface_root.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _surface_root.mouse_filter = Control.MOUSE_FILTER_IGNORE + _panel.add_child(_surface_root) + + _ticker_row = HBoxContainer.new() + _ticker_row.name = "CollapsedTickerRow" + _ticker_row.custom_minimum_size = Vector2(PANEL_WIDTH, COLLAPSED_HEIGHT) + _ticker_row.add_theme_constant_override("separation", 4) + _ticker_row.mouse_filter = Control.MOUSE_FILTER_IGNORE + _surface_root.add_child(_ticker_row) + + _ticker = Button.new() + _ticker.name = "CollapsedTicker" + _ticker.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _ticker.custom_minimum_size = Vector2(0, COLLAPSED_HEIGHT) + _ticker.focus_mode = Control.FOCUS_NONE + _ticker.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + _ticker.alignment = HORIZONTAL_ALIGNMENT_LEFT + _ticker.add_theme_font_size_override("font_size", 20) + _ticker.add_theme_color_override("font_color", TEXT_COLOR) + _ticker.add_theme_color_override("font_hover_color", TEXT_COLOR) + _ticker.add_theme_stylebox_override("normal", _empty_style()) + _ticker.add_theme_stylebox_override("hover", _hover_style()) + _ticker.add_theme_stylebox_override("pressed", _pressed_style()) + _ticker.pressed.connect(func() -> void: _set_expanded(not _expanded)) + _ticker_row.add_child(_ticker) + + _expand_button = Button.new() + _expand_button.name = "ExpandButton" + _expand_button.text = "展开" + _expand_button.custom_minimum_size = Vector2(72, COLLAPSED_HEIGHT) + _expand_button.focus_mode = Control.FOCUS_NONE + _expand_button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + _expand_button.add_theme_font_size_override("font_size", 18) + _expand_button.add_theme_color_override("font_color", ACCENT_COLOR) + _expand_button.add_theme_color_override("font_hover_color", TEXT_COLOR) + _expand_button.add_theme_stylebox_override("normal", _empty_style()) + _expand_button.add_theme_stylebox_override("hover", _hover_style()) + _expand_button.add_theme_stylebox_override("pressed", _pressed_style()) + _expand_button.pressed.connect(func() -> void: _set_expanded(true)) + _ticker_row.add_child(_expand_button) + + _content = VBoxContainer.new() + _content.name = "ExpandedContent" + _content.add_theme_constant_override("separation", 0) + _content.mouse_filter = Control.MOUSE_FILTER_IGNORE + _surface_root.add_child(_content) + + var header := HBoxContainer.new() + header.name = "Header" + header.custom_minimum_size = Vector2(0, 74) + header.add_theme_constant_override("separation", 12) + header.mouse_filter = Control.MOUSE_FILTER_IGNORE + _content.add_child(header) + + _header_icon = TextureRect.new() + _header_icon.custom_minimum_size = Vector2(56, 56) + _header_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + _header_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + _header_icon.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS + _header_icon.texture = ICON_BROADCAST + _header_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE + header.add_child(_header_icon) + + _header_title = Label.new() + _header_title.text = "世界公告" + _header_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _header_title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _header_title.add_theme_font_size_override("font_size", 26) + _header_title.add_theme_color_override("font_color", TEXT_COLOR) + _header_title.mouse_filter = Control.MOUSE_FILTER_IGNORE + header.add_child(_header_title) + + _header_banner = TextureRect.new() + _header_banner.name = "TownBanner" + _header_banner.custom_minimum_size = Vector2(88, 58) + _header_banner.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + _header_banner.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + _header_banner.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS + _header_banner.texture = ICON_BANNER + _header_banner.mouse_filter = Control.MOUSE_FILTER_IGNORE + header.add_child(_header_banner) + + var close_button := Button.new() + close_button.text = "×" + close_button.tooltip_text = "收起世界公告" + close_button.custom_minimum_size = Vector2(54, 54) + close_button.focus_mode = Control.FOCUS_NONE + close_button.add_theme_font_size_override("font_size", 34) + close_button.add_theme_color_override("font_color", MUTED_COLOR) + close_button.add_theme_color_override("font_hover_color", ACCENT_COLOR) + close_button.add_theme_stylebox_override("normal", _empty_style()) + close_button.add_theme_stylebox_override("hover", _hover_style()) + close_button.add_theme_stylebox_override("pressed", _pressed_style()) + close_button.pressed.connect(func() -> void: _set_expanded(false)) + header.add_child(close_button) + + var header_rule := ColorRect.new() + header_rule.custom_minimum_size = Vector2(0, 1) + header_rule.color = LINE_COLOR + header_rule.mouse_filter = Control.MOUSE_FILTER_IGNORE + _content.add_child(header_rule) + + _scroll = ScrollContainer.new() + _scroll.name = "AnnouncementScroll" + _scroll.custom_minimum_size = Vector2(0, 320) + _scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + _scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + _scroll.mouse_filter = Control.MOUSE_FILTER_STOP + _content.add_child(_scroll) + + _entries = VBoxContainer.new() + _entries.name = "AnnouncementEntries" + _entries.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _entries.add_theme_constant_override("separation", 0) + _scroll.add_child(_entries) + + var composer_row := HBoxContainer.new() + composer_row.name = "Composer" + composer_row.custom_minimum_size = Vector2(0, 72) + composer_row.add_theme_constant_override("separation", 10) + _content.add_child(composer_row) + + _composer = LineEdit.new() + _composer.name = "AnnouncementInput" + _composer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _composer.placeholder_text = "发布世界公告(100鲸币)..." + _composer.max_length = 160 + _composer.add_theme_font_size_override("font_size", 20) + _composer.add_theme_color_override("font_color", TEXT_COLOR) + _composer.add_theme_color_override("font_placeholder_color", MUTED_COLOR) + _composer.add_theme_stylebox_override("normal", _input_style()) + _composer.add_theme_stylebox_override("focus", _input_focus_style()) + _composer.text_submitted.connect(func(_value: String) -> void: _publish()) + composer_row.add_child(_composer) + + _send_button = Button.new() + _send_button.name = "PublishButton" + _send_button.text = "" + _send_button.icon = ICON_SEND_BUTTON + _send_button.expand_icon = true + _send_button.icon_max_width = 56 + _send_button.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS + _send_button.tooltip_text = "发布世界公告" + _send_button.custom_minimum_size = Vector2(56, 56) + _send_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER + _send_button.focus_mode = Control.FOCUS_NONE + _send_button.add_theme_color_override("icon_normal_color", Color.WHITE) + _send_button.add_theme_color_override("icon_hover_color", Color("eef9ff")) + _send_button.add_theme_color_override("icon_pressed_color", Color("bedcf5")) + _send_button.add_theme_stylebox_override("normal", _empty_style()) + _send_button.add_theme_stylebox_override("hover", _empty_style()) + _send_button.add_theme_stylebox_override("pressed", _empty_style()) + _send_button.add_theme_stylebox_override("focus", _empty_style()) + _send_button.pressed.connect(_publish) + composer_row.add_child(_send_button) + +func _subscribe_to_events() -> void: + var event_system := get_node_or_null("/root/EventSystem") + if event_system == null: + return + event_system.call("connect_event", EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self) + event_system.call("connect_event", EventNames.CHAT_MESSAGE_SENT, _on_chat_message_sent, self) + event_system.call("connect_event", EventNames.CHAT_ERROR_OCCURRED, _on_chat_error, self) + +func _on_chat_message_sent(data: Dictionary) -> void: + if not _publish_pending or not bool(data.get("world_bulletin", false)): + return + _composer.clear() + _set_publish_pending(false) + +func _on_chat_error(data: Dictionary) -> void: + if not _publish_pending or not bool(data.get("world_bulletin", false)): + return + _set_publish_pending(false) + _composer.grab_focus() + +func _set_publish_pending(pending: bool) -> void: + _publish_pending = pending + _composer.editable = not pending + _send_button.disabled = pending + +func _seed_preview_items() -> void: + _announcements = [ + {"sender": "小海豚", "time": "12:08", "content": "今晚海湾烟花节 20:00 开始", "category": "活动", "pinned": true}, + {"sender": "珊珊酱", "time": "12:12", "content": "需要 2 名采集伙伴一起出海", "category": "招募"}, + {"sender": "海盐", "time": "12:20", "content": "新地图补给点已刷新", "category": "通知"}, + {"sender": "系统", "time": "12:30", "content": "世界频道维护将在 15 分钟后结束", "category": "通知"} + ] + _last_preview = str(_announcements[0].get("content", _last_preview)) + _render_entries() + +func _on_chat_message_received(data: Dictionary) -> void: + # 只展示后端确认并已扣费的世界公告,普通全局聊天不进公告栏。 + if not bool(data.get("world_bulletin", data.get("worldBulletin", false))): + return + var scope := str(data.get("scope", data.get("channel", "global"))).to_lower() + var tab := str(data.get("tab", "world")).to_lower() + if scope in ["private", "whisper"] or tab in ["private", "whisper", "friends"]: + return + var content := str(data.get("content", data.get("message", ""))).strip_edges() + if content.is_empty(): + return + var sender := str(data.get("from_user", data.get("username", "玩家"))).strip_edges() + if sender.is_empty(): + sender = "玩家" + var category := "通知" + if sender == "系统" or str(data.get("sender_type", "")).to_lower() == "system": + category = "通知" + _announcements.push_front({ + "sender": sender, + "time": Time.get_time_string_from_system().left(5), + "content": content, + "category": category + }) + while _announcements.size() > MAX_ITEMS: + _announcements.pop_back() + _last_preview = content + if not _expanded: + _unread += 1 + _render_entries() + _update_ticker() + +func _set_expanded(expanded: bool, immediate := false) -> void: + _expanded = expanded + if _expanded: + _unread = 0 + _panel.custom_minimum_size = Vector2(PANEL_WIDTH, EXPANDED_HEIGHT) + _panel.offset_top = -EDGE_MARGIN - EXPANDED_HEIGHT + _ticker_row.hide() + _content.show() + _render_entries() + else: + _panel.custom_minimum_size = Vector2(PANEL_WIDTH, COLLAPSED_HEIGHT) + _panel.offset_top = -EDGE_MARGIN - COLLAPSED_HEIGHT + _content.hide() + _ticker_row.show() + _update_ticker() + if immediate: + _panel.modulate.a = 1.0 + +func _update_ticker() -> void: + if not is_instance_valid(_ticker): + return + var unread_text := " · %d" % _unread if _unread > 0 else "" + _ticker.text = " 世界公告 %s%s" % [_last_preview.left(25), unread_text] + +func _render_entries() -> void: + if not is_instance_valid(_entries): + return + for child in _entries.get_children(): + child.queue_free() + if _announcements.is_empty(): + var empty := VBoxContainer.new() + empty.custom_minimum_size = Vector2(0, 210) + empty.alignment = BoxContainer.ALIGNMENT_CENTER + _entries.add_child(empty) + var whale := TextureRect.new() + whale.texture = ICON_EMPTY + whale.custom_minimum_size = Vector2(0, 92) + whale.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + whale.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + whale.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS + whale.mouse_filter = Control.MOUSE_FILTER_IGNORE + empty.add_child(whale) + var empty_label := Label.new() + empty_label.text = "暂无公告" + empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + empty_label.add_theme_color_override("font_color", MUTED_COLOR) + empty.add_child(empty_label) + return + for item in _announcements: + _entries.add_child(_build_entry(item)) + +func _build_entry(item: Dictionary) -> Control: + var card := PanelContainer.new() + card.custom_minimum_size = Vector2(0, 92) + card.size_flags_horizontal = Control.SIZE_EXPAND_FILL + card.mouse_filter = Control.MOUSE_FILTER_IGNORE + var card_style := StyleBoxFlat.new() + card_style.bg_color = Color(1, 1, 1, 0.55) + card_style.border_color = LINE_COLOR + card_style.set_border_width_all(1) + card_style.set_corner_radius_all(10) + card.add_theme_stylebox_override("panel", card_style) + + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 10) + margin.add_theme_constant_override("margin_top", 7) + margin.add_theme_constant_override("margin_right", 10) + margin.add_theme_constant_override("margin_bottom", 7) + card.add_child(margin) + + var card_row := HBoxContainer.new() + card_row.add_theme_constant_override("separation", 8) + card_row.mouse_filter = Control.MOUSE_FILTER_IGNORE + margin.add_child(card_row) + + var row := VBoxContainer.new() + row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_theme_constant_override("separation", 2) + row.mouse_filter = Control.MOUSE_FILTER_IGNORE + card_row.add_child(row) + + var meta := HBoxContainer.new() + meta.custom_minimum_size = Vector2(0, 30) + meta.add_theme_constant_override("separation", 6) + meta.mouse_filter = Control.MOUSE_FILTER_IGNORE + row.add_child(meta) + + if bool(item.get("pinned", false)): + var pin := TextureRect.new() + pin.custom_minimum_size = Vector2(28, 28) + pin.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + pin.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + pin.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS + pin.texture = ICON_PIN + pin.tooltip_text = "置顶公告" + pin.mouse_filter = Control.MOUSE_FILTER_IGNORE + meta.add_child(pin) + + var sender := Label.new() + sender.text = str(item.get("sender", "玩家")) + sender.add_theme_font_size_override("font_size", 20) + sender.add_theme_color_override("font_color", TEXT_COLOR) + meta.add_child(sender) + + var category := Label.new() + category.text = str(item.get("category", "通知")) + category.add_theme_font_size_override("font_size", 16) + category.add_theme_color_override("font_color", Color.WHITE) + category.add_theme_stylebox_override("normal", _category_style(category.text)) + meta.add_child(category) + + if str(item.get("category", "")) == "招募": + var recruit := TextureRect.new() + recruit.custom_minimum_size = Vector2(32, 28) + recruit.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + recruit.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + recruit.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS + recruit.texture = ICON_RECRUIT + recruit.mouse_filter = Control.MOUSE_FILTER_IGNORE + meta.add_child(recruit) + + var time := Label.new() + time.text = str(item.get("time", "")) + time.size_flags_horizontal = Control.SIZE_EXPAND_FILL + time.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + time.add_theme_font_size_override("font_size", 16) + time.add_theme_color_override("font_color", MUTED_COLOR) + meta.add_child(time) + + var body := Label.new() + body.text = str(item.get("content", "")) + body.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + body.custom_minimum_size = Vector2(0, 30) + body.size_flags_horizontal = Control.SIZE_EXPAND_FILL + body.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + body.add_theme_font_size_override("font_size", 20) + body.add_theme_color_override("font_color", TEXT_COLOR) + body.mouse_filter = Control.MOUSE_FILTER_IGNORE + row.add_child(body) + + return card + +func _category_style(category: String) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.set_corner_radius_all(6) + style.content_margin_left = 7 + style.content_margin_right = 7 + style.content_margin_top = 3 + style.content_margin_bottom = 3 + match category: + "活动": style.bg_color = Color("55bd8a") + "招募": style.bg_color = Color("f0aa4c") + _: style.bg_color = ACCENT_COLOR + return style + +func _publish() -> void: + if _publish_pending: + return + var content := _composer.text.strip_edges() + if content.is_empty(): + return + var manager := get_node_or_null("/root/ChatManager") + if manager == null or not manager.has_method("send_world_bulletin"): + return + if not bool(manager.call("send_world_bulletin", content)): + return + _set_publish_pending(true) + _set_expanded(true) + +func _surface_style() -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = SURFACE_COLOR + style.border_color = Color("b8d9f4") + style.set_border_width_all(2) + style.set_corner_radius_all(18) + style.shadow_color = Color(0.16, 0.40, 0.65, 0.18) + style.shadow_size = 10 + style.shadow_offset = Vector2(0, 4) + style.content_margin_left = 14 + style.content_margin_top = 12 + style.content_margin_right = 14 + style.content_margin_bottom = 12 + return style + +func _empty_style() -> StyleBoxEmpty: + return StyleBoxEmpty.new() + +func _hover_style() -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = Color(0.88, 0.95, 1.0, 0.85) + style.set_corner_radius_all(12) + return style + +func _pressed_style() -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = Color(0.78, 0.91, 1.0, 0.9) + style.set_corner_radius_all(12) + return style + +func _input_style() -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = Color(1, 1, 1, 0.9) + style.border_color = Color("c4def4") + style.set_border_width_all(1) + style.set_corner_radius_all(12) + style.content_margin_left = 10 + style.content_margin_right = 10 + return style + +func _input_focus_style() -> StyleBoxFlat: + var style := _input_style() + style.border_color = ACCENT_COLOR + style.set_border_width_all(2) + return style + +func _accent_style() -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = ACCENT_COLOR + style.set_corner_radius_all(14) + return style + +func _accent_hover_style() -> StyleBoxFlat: + var style := _accent_style() + style.bg_color = Color("53a8f4") + return style + +func _accent_pressed_style() -> StyleBoxFlat: + var style := _accent_style() + style.bg_color = Color("247bc7") + return style diff --git a/scenes/ui/WorldBulletinPanel.gd.uid b/scenes/ui/WorldBulletinPanel.gd.uid new file mode 100644 index 0000000..d87c06c --- /dev/null +++ b/scenes/ui/WorldBulletinPanel.gd.uid @@ -0,0 +1 @@ +uid://d1dka1huar71o diff --git a/scenes/ui/WorldBulletinPanel.tscn b/scenes/ui/WorldBulletinPanel.tscn new file mode 100644 index 0000000..cfea340 --- /dev/null +++ b/scenes/ui/WorldBulletinPanel.tscn @@ -0,0 +1,13 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scenes/ui/WorldBulletinPanel.gd" id="1_world_bulletin"] + +[node name="WorldBulletinPanel" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +script = ExtResource("1_world_bulletin") diff --git a/scripts/build_desktop.sh b/scripts/build_desktop.sh new file mode 100755 index 0000000..b660a3a --- /dev/null +++ b/scripts/build_desktop.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +GODOT_BIN="${GODOT_BIN:-/Applications/Godot.app/Contents/MacOS/Godot}" +TARGET="${1:-all}" +VERSION="${WHALETOWN_VERSION:-1.0.0}" + +if [[ ! -x "$GODOT_BIN" ]]; then + echo "Godot executable not found: $GODOT_BIN" >&2 + echo "Set GODOT_BIN to the Godot 4.6.2 executable path." >&2 + exit 1 +fi + +export_target() { + local preset="$1" + local output="$2" + local marker + marker="$(mktemp /tmp/whaletown-export.XXXXXX)" + mkdir -p "$(dirname "$output")" + echo "Exporting $preset -> $output" + "$GODOT_BIN" --headless --path "$PROJECT_DIR" --export-release "$preset" "$output" + if [[ ! -e "$output" ]] || [[ -z "$(find "$output" -newer "$marker" -print -quit 2>/dev/null)" ]]; then + echo "Export failed: Godot did not create a fresh artifact at $output" >&2 + exit 1 + fi +} + +package_artifacts() { + local release_dir="$PROJECT_DIR/build/desktop/release" + local mac_app="$PROJECT_DIR/build/desktop/macos/WhaleTown.app" + local windows_exe="$PROJECT_DIR/build/desktop/windows/WhaleTown.exe" + local mac_zip="$release_dir/WhaleTown-macOS-$VERSION.zip" + local windows_zip="$release_dir/WhaleTown-Windows-x86_64-$VERSION.zip" + + if [[ ! -d "$mac_app" ]] || [[ ! -f "$windows_exe" ]]; then + echo "Both macOS and Windows artifacts are required before packaging." >&2 + exit 1 + fi + + mkdir -p "$release_dir" + echo "Packaging $mac_zip" + /usr/bin/ditto -c -k --sequesterRsrc --keepParent "$mac_app" "$mac_zip" + echo "Packaging $windows_zip" + /usr/bin/ditto -c -k --keepParent "$windows_exe" "$windows_zip" + ( + cd "$release_dir" + shasum -a 256 "$(basename "$mac_zip")" "$(basename "$windows_zip")" > SHA256SUMS.txt + ) + echo "Release packages are under: $release_dir" +} + +case "$TARGET" in + macos) + export_target "WhaleTown macOS" "$PROJECT_DIR/build/desktop/macos/WhaleTown.app" + ;; + windows) + export_target "WhaleTown Windows" "$PROJECT_DIR/build/desktop/windows/WhaleTown.exe" + ;; + all) + export_target "WhaleTown macOS" "$PROJECT_DIR/build/desktop/macos/WhaleTown.app" + export_target "WhaleTown Windows" "$PROJECT_DIR/build/desktop/windows/WhaleTown.exe" + ;; + package) + package_artifacts + ;; + release) + export_target "WhaleTown macOS" "$PROJECT_DIR/build/desktop/macos/WhaleTown.app" + export_target "WhaleTown Windows" "$PROJECT_DIR/build/desktop/windows/WhaleTown.exe" + package_artifacts + ;; + *) + echo "Usage: $0 [macos|windows|all|package|release]" >&2 + exit 2 + ;; +esac + +echo "Desktop export complete. Files are under: $PROJECT_DIR/build/desktop" diff --git a/scripts/build_progressive_web.sh b/scripts/build_progressive_web.sh index 6d7aa2e..b995c86 100755 --- a/scripts/build_progressive_web.sh +++ b/scripts/build_progressive_web.sh @@ -41,6 +41,8 @@ work_zone=$(build_pack "work_zone" "Scene Work Zone") cafe=$(build_pack "cafe_interior" "Scene Cafe") personal_space=$(build_pack "personal_space" "Scene Personal Space") auth=$(build_pack "auth" "Scene Auth") +auth_pack_size=$(printf '%s' "$auth" | jq -r '.size') +perl -0pi -e "s/__WHALETOWN_AUTH_PACK_SIZE__/$auth_pack_size/g" "$WEB_DIR/index.html" jq -n \ --argjson square "$square" \ diff --git a/scripts/prepare_site_release.sh b/scripts/prepare_site_release.sh new file mode 100755 index 0000000..0e5450a --- /dev/null +++ b/scripts/prepare_site_release.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +SITE_DIR="$PROJECT_DIR/build/site" +WEB_DIR="$PROJECT_DIR/build/web" +HOMEPAGE_DIR="$(cd "$PROJECT_DIR/../homepage" && pwd)" +RELEASE_DIR="$PROJECT_DIR/build/desktop/release" + +required_files=( + "$WEB_DIR/index.html" + "$WEB_DIR/index.js" + "$WEB_DIR/index.wasm" + "$RELEASE_DIR/WhaleTown-macOS-1.0.0.zip" + "$RELEASE_DIR/WhaleTown-Windows-x86_64-1.0.0.zip" +) + +for file in "${required_files[@]}"; do + if [[ ! -f "$file" ]]; then + echo "Missing required release artifact: $file" >&2 + echo "Build the Web and desktop releases before preparing the site." >&2 + exit 1 + fi +done + +rm -rf "$SITE_DIR" +mkdir -p "$SITE_DIR/play" "$SITE_DIR/downloads" + +cp -R "$HOMEPAGE_DIR/." "$SITE_DIR/" +if [[ -d "$SITE_DIR/.git" ]]; then + find "$SITE_DIR/.git" -depth -delete +fi +find "$SITE_DIR" -name '*.import' -delete +find "$SITE_DIR" -name '.DS_Store' -delete +cp -R "$WEB_DIR/." "$SITE_DIR/play/" +find "$SITE_DIR/play" -name '*.import' -delete +find "$SITE_DIR/play" -name '.DS_Store' -delete +cp "$RELEASE_DIR/WhaleTown-macOS-1.0.0.zip" "$SITE_DIR/downloads/" +cp "$RELEASE_DIR/WhaleTown-Windows-x86_64-1.0.0.zip" "$SITE_DIR/downloads/" +cp "$RELEASE_DIR/SHA256SUMS.txt" "$SITE_DIR/downloads/" + +echo "Site release prepared in: $SITE_DIR" +echo " / Landing page" +echo " /play/ Web client" +echo " /downloads Desktop client ZIP files" diff --git a/scripts/test_network_npc_animations.gd b/scripts/test_network_npc_animations.gd new file mode 100644 index 0000000..2e5d3db --- /dev/null +++ b/scripts/test_network_npc_animations.gd @@ -0,0 +1,48 @@ +extends SceneTree + +const NETWORK_NPC_SCENE: PackedScene = preload("res://scenes/characters/network_npc.tscn") + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + var npc := NETWORK_NPC_SCENE.instantiate() + root.add_child(npc) + npc.call("apply_snapshot", { + "npcId": "npc_niulai", + "name": "牛来", + "scene": "niulai_ambassador", + "version": 1, + "x": 0, + "y": 0, + "movementState": "idle", + }) + var animationPlayer := npc.get_node("AnimationPlayer") as AnimationPlayer + assert(npc.collision_layer == 2, "NPC must occupy the dedicated NPC collision layer") + assert(npc.collision_mask == 1, "NPC collision mask must include static map collision") + var npcShape := (npc.get_node("CollisionShape2D") as CollisionShape2D).shape as RectangleShape2D + assert(npcShape.size == Vector2(52, 24), "Niulai must use the adjusted physical footprint") + var nameplate := npc.get_node("Nameplate") as Label + assert(nameplate.get_theme_font_size("font_size") == 16, "NPC names must use the player name font size") + assert(nameplate.get_theme_constant("outline_size") == 3, "NPC names must use the player name outline") + assert(nameplate.get_theme_stylebox("normal") is StyleBoxEmpty, "NPC names must not use the old pill background") + npc.call("_configure_visual", "town_mayor") + assert(is_equal_approx(npc.nameplateOffsetY, -52.0), "The mayor name must follow the visible propeller instead of transparent frame padding") + npc.call("_configure_visual", "niulai_ambassador") + var library := animationPlayer.get_animation_library("") + var expectedRows := {"down": 0, "up": 1, "right": 2, "left": 3} + for direction in expectedRows: + var animationName := "walk_%s" % direction + assert(library.has_animation(animationName), "missing %s" % animationName) + var animation := library.get_animation(animationName) + assert(animation.loop_mode == Animation.LOOP_LINEAR, "%s must loop" % animationName) + assert(animation.track_get_key_count(0) == 4, "%s must contain four frames" % animationName) + for column in range(4): + assert( + animation.track_get_key_value(0, column) == expectedRows[direction] * 4 + column, + "%s uses the wrong spritesheet row" % animationName, + ) + npc.call("interact") + assert(root.get_node_or_null("WorldChatBubbleLayer") == null, "NPC interaction must not create a world bubble layer") + print("NETWORK_NPC_ANIMATIONS_OK") + quit() diff --git a/scripts/test_network_npc_animations.gd.uid b/scripts/test_network_npc_animations.gd.uid new file mode 100644 index 0000000..1a47632 --- /dev/null +++ b/scripts/test_network_npc_animations.gd.uid @@ -0,0 +1 @@ +uid://cadhx6wgvrk3y diff --git a/scripts/test_npc_dialogue_ui.gd b/scripts/test_npc_dialogue_ui.gd new file mode 100644 index 0000000..77321cc --- /dev/null +++ b/scripts/test_npc_dialogue_ui.gd @@ -0,0 +1,49 @@ +extends SceneTree + +const CHAT_UI_SCENE: PackedScene = preload("res://scenes/ui/ChatUI.tscn") + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + var chatUi := CHAT_UI_SCENE.instantiate() + root.add_child(chatUi) + await process_frame + + chatUi.call("start_npc_whisper", "npc_niulai", "牛来", "欢迎来到鲸鱼小镇!") + await process_frame + var panel := chatUi.get_node("ChatPanel") as PanelContainer + var worldTab := chatUi.get_node("ChatPanel/PanelMargin/ContentVBox/Tabs/PopularTab") as Control + var npcTabButton := chatUi.get_node("ChatPanel/PanelMargin/ContentVBox/Tabs/RecentTab/RecentTabButton") as Button + var friendsTab := chatUi.get_node("ChatPanel/PanelMargin/ContentVBox/Tabs/FriendsTab") as Control + var inputRow := chatUi.get_node("ChatPanel/PanelMargin/ContentVBox/InputRow") as Control + assert(panel.visible, "NPC interaction must open the dialogue panel") + assert(is_equal_approx(panel.anchor_left, 0.5) and is_equal_approx(panel.anchor_right, 0.5), "NPC dialogue must be horizontally centered") + assert((panel.get_theme_stylebox("panel") as StyleBoxFlat).border_width_left == 4, "NPC dialogue must use the framed dialogue style") + assert(not worldTab.visible and not friendsTab.visible, "Channel tabs must be hidden during an NPC dialogue") + assert(npcTabButton.text == "牛来", "The dialogue header must show the NPC name") + assert(inputRow.visible, "AI NPC dialogue must retain the message input") + var dialogueMessage := chatUi.find_child("ChatMessage", true, false) as Control + assert(dialogueMessage != null, "NPC greeting must render in the dialogue") + assert(not dialogueMessage.get_node("MessageRow/LeftAvatarPanel").visible, "NPC dialogue messages must not render chat bubbles or avatars") + root.size = Vector2i(640, 360) + await process_frame + var mobileRect := panel.get_rect() + var mobileCanvasSize: Vector2 = chatUi.size + assert(mobileRect.position.x >= 0.0 and mobileRect.end.x <= mobileCanvasSize.x, "NPC dialogue must fit the mobile canvas horizontally") + assert(mobileRect.position.y >= 0.0 and mobileRect.end.y <= mobileCanvasSize.y, "NPC dialogue must fit the mobile canvas vertically") + + chatUi.call("hide_chat", true) + await process_frame + assert(is_equal_approx(panel.anchor_left, 0.024), "Closing an NPC dialogue must restore the normal chat layout") + assert(worldTab.visible and friendsTab.visible, "Closing an NPC dialogue must restore channel tabs") + + chatUi.call("show_npc_dialogue", "虾小满", "码头今天风平浪静。") + await process_frame + assert(panel.visible, "Static NPC interaction must use the same dialogue panel") + assert(not inputRow.visible, "Static NPC dialogue must not show an unusable input") + assert(npcTabButton.text == "虾小满", "Static NPC dialogue must show its speaker") + assert(root.get_node_or_null("WorldChatBubbleLayer") == null, "NPC dialogue must not create a world bubble layer") + + print("NPC_DIALOGUE_UI_OK") + quit() diff --git a/scripts/test_npc_dialogue_ui.gd.uid b/scripts/test_npc_dialogue_ui.gd.uid new file mode 100644 index 0000000..1e1cbfa --- /dev/null +++ b/scripts/test_npc_dialogue_ui.gd.uid @@ -0,0 +1 @@ +uid://bi7lnt88l1qoo diff --git a/scripts/test_world_npc_navigation.gd b/scripts/test_world_npc_navigation.gd new file mode 100644 index 0000000..38796c2 --- /dev/null +++ b/scripts/test_world_npc_navigation.gd @@ -0,0 +1,105 @@ +extends SceneTree + +const MAP_SCENES := { + "whale_port": "res://scenes/Maps/square.tscn", + "work_zone": "res://scenes/Maps/work_zone.tscn", + "whale_cafe": "res://scenes/Maps/cafe_interior.tscn", +} +const MAX_NPC_FOOTPRINT := Vector2(60, 28) +const ROUTE_SAMPLE_DISTANCE := 8.0 + +var _failures: Array[String] = [] + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + var arguments := OS.get_cmdline_user_args() + assert(arguments.size() == 1, "pass the exported world NPC graph JSON path after --") + var graph := JSON.parse_string(FileAccess.get_file_as_string(arguments[0])) as Dictionary + assert(not graph.is_empty(), "world NPC graph JSON is invalid") + var locationsById := {} + for locationValue in graph.get("locations", []): + var location := locationValue as Dictionary + locationsById[str(location.get("id", ""))] = location + for mapId in MAP_SCENES: + await _validate_map(str(mapId), str(MAP_SCENES[mapId]), graph, locationsById) + if not _failures.is_empty(): + for failure in _failures: + push_error(failure) + quit(1) + return + print("WORLD_NPC_NAVIGATION_OK") + quit() + +func _validate_map(mapId: String, scenePath: String, graph: Dictionary, locationsById: Dictionary) -> void: + var mapScene := (load(scenePath) as PackedScene).instantiate() as Node2D + for uiName in ["ChatUI", "WorldBulletinPanel"]: + var uiNode := mapScene.find_child(uiName, true, false) + if uiNode != null: + uiNode.free() + root.add_child(mapScene) + await process_frame + for locationValue in graph.get("locations", []): + var location := locationValue as Dictionary + if str(location.get("mapId", "")) != mapId: + continue + for slotIndex in range((location.get("slots", []) as Array).size()): + var slot := (location.get("slots", []) as Array)[slotIndex] as Dictionary + _validate_clear_point( + mapScene, Vector2(float(slot.get("x", 0)), float(slot.get("y", 0))), + "%s slot %d" % [location.get("id", ""), slotIndex], + ) + for edgeValue in graph.get("edges", []): + var edge := edgeValue as Dictionary + if str(edge.get("kind", "")) != "walk": + continue + var from := locationsById.get(str(edge.get("from", "")), {}) as Dictionary + var to := locationsById.get(str(edge.get("to", "")), {}) as Dictionary + if str(from.get("mapId", "")) != mapId or str(to.get("mapId", "")) != mapId: + continue + var fromCenter := Vector2(float(from.get("x", 0)), float(from.get("y", 0))) + var toCenter := Vector2(float(to.get("x", 0)), float(to.get("y", 0))) + var fromPoints: Array[Vector2] = [fromCenter] + var toPoints: Array[Vector2] = [toCenter] + for slotValue in from.get("slots", []): + var slot := slotValue as Dictionary + fromPoints.append(Vector2(float(slot.get("x", 0)), float(slot.get("y", 0)))) + for slotValue in to.get("slots", []): + var slot := slotValue as Dictionary + toPoints.append(Vector2(float(slot.get("x", 0)), float(slot.get("y", 0)))) + for fromPoint in fromPoints: + _validate_clear_segment(mapScene, fromPoint, toCenter, "%s -> %s" % [from.get("id", ""), to.get("id", "")]) + for toPoint in toPoints: + _validate_clear_segment(mapScene, fromCenter, toPoint, "%s -> %s" % [from.get("id", ""), to.get("id", "")]) + mapScene.queue_free() + await process_frame + +func _validate_clear_segment(mapScene: Node2D, from: Vector2, to: Vector2, label: String) -> void: + var samples := maxi(1, int(ceil(from.distance_to(to) / ROUTE_SAMPLE_DISTANCE))) + for sampleIndex in range(samples + 1): + var point := from.lerp(to, float(sampleIndex) / float(samples)) + if not _static_colliders_at(mapScene, point).is_empty(): + _failures.append("route intersects static collision: %s at %s" % [label, point]) + return + +func _validate_clear_point(mapScene: Node2D, point: Vector2, label: String) -> void: + var colliders := _static_colliders_at(mapScene, point) + if not colliders.is_empty(): + _failures.append("slot intersects static collision: %s at %s (%s)" % [label, point, ", ".join(colliders)]) + +func _static_colliders_at(mapScene: Node2D, point: Vector2) -> Array[String]: + var shape := RectangleShape2D.new() + shape.size = MAX_NPC_FOOTPRINT + var query := PhysicsShapeQueryParameters2D.new() + query.shape = shape + query.transform = Transform2D(0.0, point) + query.collision_mask = 1 + query.collide_with_areas = false + query.collide_with_bodies = true + var colliders: Array[String] = [] + for hit in mapScene.get_world_2d().direct_space_state.intersect_shape(query, 64): + var collider := hit.get("collider") as CollisionObject2D + if collider is StaticBody2D and not colliders.has(str(collider.get_path())): + colliders.append(str(collider.get_path())) + return colliders diff --git a/scripts/test_world_npc_navigation.gd.uid b/scripts/test_world_npc_navigation.gd.uid new file mode 100644 index 0000000..bcbb094 --- /dev/null +++ b/scripts/test_world_npc_navigation.gd.uid @@ -0,0 +1 @@ +uid://ccc351itj8ilh diff --git a/web/progressive_shell.html b/web/progressive_shell.html index fc32929..ee8e156 100644 --- a/web/progressive_shell.html +++ b/web/progressive_shell.html @@ -21,7 +21,7 @@
- +正在加载小镇 0%
@@ -36,30 +36,39 @@ const GODOT_THREADS_ENABLED = $GODOT_THREADS_ENABLED; const CORE_PACK_URL = '__WHALETOWN_CORE_PACK__'; const CORE_PACK_SIZE = __WHALETOWN_CORE_PACK_SIZE__; + const AUTH_PACK_SIZE = __WHALETOWN_AUTH_PACK_SIZE__; GODOT_CONFIG.mainPack = CORE_PACK_URL; GODOT_CONFIG.fileSizes[CORE_PACK_URL] = CORE_PACK_SIZE; const shell = document.getElementById('loading-shell'); const status = document.getElementById('status'); const bar = document.getElementById('bar'); const failure = document.getElementById('failure'); + const canvas = document.getElementById('canvas'); + canvas.addEventListener('pointerdown', function () { canvas.focus(); }); + canvas.addEventListener('keydown', function (event) { + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' ', 'w', 'a', 's', 'd', 'W', 'A', 'S', 'D'].includes(event.key)) { + event.preventDefault(); + } + }); const engine = new Engine(GODOT_CONFIG); + let corePhaseTotal = 0; + + function updateTownProgress(loaded, total) { + if (total <= 0) return; + const percent = Math.min(100, Math.round(loaded / total * 100)); + bar.style.width = percent + '%'; + status.textContent = '正在加载小镇 ' + percent + '%'; + } window.whaletownGodotReady = function () { bar.style.width = '100%'; shell.classList.add('done'); window.setTimeout(function () { shell.style.display = 'none'; }, 220); - document.getElementById('canvas').focus(); + canvas.focus(); }; window.whaletownPackProgress = function (downloaded, total) { - const downloadedMb = downloaded / 1048576; - if (total > 0) { - const totalMb = total / 1048576; - const percent = Math.min(100, Math.round(downloaded / total * 100)); - bar.style.width = percent + '%'; - status.textContent = '正在加载完整登录界面 ' + downloadedMb.toFixed(1) + ' / ' + totalMb.toFixed(1) + ' MB'; - } else { - status.textContent = '正在加载完整登录界面 ' + downloadedMb.toFixed(1) + ' MB'; - } + const authTotal = total > 0 ? total : AUTH_PACK_SIZE; + updateTownProgress(corePhaseTotal + downloaded, corePhaseTotal + authTotal); }; const missing = Engine.getMissingFeatures({ threads: GODOT_THREADS_ENABLED }); @@ -70,13 +79,17 @@ engine.startGame({ onProgress(current, total) { if (total <= 0) return; - const percent = Math.min(100, Math.round(current / total * 100)); - bar.style.width = percent + '%'; - status.textContent = '正在加载小镇 ' + percent + '%'; + corePhaseTotal = total; + if (current >= total) { + const percent = Math.min(100, Math.round(current / (total + AUTH_PACK_SIZE) * 100)); + bar.style.width = percent + '%'; + status.textContent = '正在加载小镇,正在启动登录界面...'; + } else { + updateTownProgress(current, total + AUTH_PACK_SIZE); + } }, }).then(function () { - status.textContent = '正在加载完整登录界面...'; - bar.style.width = '0'; + status.textContent = '正在加载小镇,正在启动登录界面...'; }).catch(function (error) { failure.style.display = 'block'; failure.textContent = error && error.message ? error.message : '主程序加载失败,请刷新重试';