feat: expand multiplayer, chat, and release support

- add network NPC synchronization and dialogue interactions
- add world bulletin publishing and display
- improve authentication, session refresh, and appearance sync
- synchronize player direction and movement animations
- improve input focus and progressive Web loading
- add macOS/Windows builds and deployment configuration
- include required fonts, shaders, and runtime assets
This commit is contained in:
2026-09-08 21:37:43 +08:00
parent 175621f66c
commit fc6c3c1bd3
87 changed files with 4546 additions and 506 deletions

3
.gitignore vendored
View File

@@ -36,6 +36,8 @@ Godot/
*.log *.log
*.tmp *.tmp
*.temp *.temp
/tmp/
/.playwright-cli/
# Build outputs # Build outputs
build/ build/
@@ -51,6 +53,7 @@ dist/
# Credentials and local secrets # Credentials and local secrets
secrets/ secrets/
/.secrets/
*.pem *.pem
*.key *.key
*.p12 *.p12

37
DESKTOP_BUILD.md Normal file
View File

@@ -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 校验值。

View File

@@ -19,6 +19,12 @@ const REMOTE_PLAYER_JOINED = "remote_player_joined"
const REMOTE_PLAYER_LEFT = "remote_player_left" const REMOTE_PLAYER_LEFT = "remote_player_left"
const REMOTE_PLAYER_POSITION_UPDATED = "remote_player_position_updated" const REMOTE_PLAYER_POSITION_UPDATED = "remote_player_position_updated"
const REMOTE_PLAYERS_SNAPSHOT_READY = "remote_players_snapshot_ready" 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_READY = "remote_skin_ready"
const REMOTE_SKIN_FAILED = "remote_skin_failed" const REMOTE_SKIN_FAILED = "remote_skin_failed"
const PLAYER_HEALTH_CHANGED = "player_health_changed" const PLAYER_HEALTH_CHANGED = "player_health_changed"

View File

@@ -13,6 +13,16 @@ const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const REQUEST_TIMEOUT: float = 12.0 const REQUEST_TIMEOUT: float = 12.0
var _activeRequests: Array[HTTPRequest] = [] 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: func _exit_tree() -> void:
for request in _activeRequests: for request in _activeRequests:
@@ -20,6 +30,7 @@ func _exit_tree() -> void:
request.cancel_request() request.cancel_request()
request.queue_free() request.queue_free()
_activeRequests.clear() _activeRequests.clear()
_pendingAuthRetries.clear()
func get_json(endpoint: String, callback: Callable, authenticated: bool = true) -> void: func get_json(endpoint: String, callback: Callable, authenticated: bool = true) -> void:
request_json(endpoint, {}, callback, HTTPClient.METHOD_GET, authenticated) 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) 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: 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() var request := HTTPRequest.new()
request.timeout = REQUEST_TIMEOUT request.timeout = REQUEST_TIMEOUT
add_child(request) 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: request.request_completed.connect(func(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
_activeRequests.erase(request) _activeRequests.erase(request)
request.queue_free() 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] 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) headers.append("Authorization: Bearer %s" % accessToken)
return headers 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: if result != HTTPRequest.RESULT_SUCCESS:
var message := "网络请求失败: %s" % _http_result_to_string(result) var message := "网络请求失败: %s" % _http_result_to_string(result)
request_failed.emit(endpoint, message) request_failed.emit(endpoint, message)
callback.call(false, {}, {"message": message}) callback.call(false, {}, {"message": message})
return 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 bodyText := body.get_string_from_utf8()
var json := JSON.new() var json := JSON.new()
if json.parse(bodyText) != OK: 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", "请求失败"))) request_failed.emit(endpoint, str(errorInfo.get("message", "请求失败")))
callback.call(false, response, errorInfo) 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: func _http_result_to_string(result: int) -> String:
match result: match result:
HTTPRequest.RESULT_CHUNKED_BODY_SIZE_MISMATCH: HTTPRequest.RESULT_CHUNKED_BODY_SIZE_MISMATCH:

View File

@@ -21,6 +21,7 @@ const REMOTE_SKIN_MAX_RETRIES: int = 3
const REMOTE_SKIN_CACHE_DIR: String = "user://skin_cache" const REMOTE_SKIN_CACHE_DIR: String = "user://skin_cache"
const AVATAR_CORNER_RADIUS_RATIO: float = 0.22 const AVATAR_CORNER_RADIUS_RATIO: float = 0.22
const DEFAULT_CHARACTER_TEXTURE_PATH: String = "res://assets/characters/player_pixel_spritesheet.png" 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 NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const SKINS: Array[Dictionary] = [ const SKINS: Array[Dictionary] = [
@@ -99,9 +100,12 @@ var _remoteSkinRetryCounts: Dictionary = {}
var _accountAvatarUrl: String = "" var _accountAvatarUrl: String = ""
var _accountAvatarTexture: Texture2D var _accountAvatarTexture: Texture2D
var _profileSyncSuppressed: bool = false var _profileSyncSuppressed: bool = false
var _confirmedSkinId: String = FALLBACK_SKIN_ID
var _pendingSkinId: String = ""
func _ready() -> void: func _ready() -> void:
_load() _load()
_confirmedSkinId = selectedSkinId
_connect_auth_events() _connect_auth_events()
_emit_profile_changed() _emit_profile_changed()
@@ -226,12 +230,17 @@ func set_selected_skin(skinId: String, allowLocked: bool = false) -> bool:
if normalizedId == selectedSkinId: if normalizedId == selectedSkinId:
return true return true
var shouldSync := not _profileSyncSuppressed and _is_account_authenticated()
if shouldSync:
_pendingSkinId = normalizedId
selectedSkinId = normalizedId selectedSkinId = normalizedId
_save() _save()
_emit_skin_changed() _emit_skin_changed()
_emit_profile_changed() _emit_profile_changed()
if not _profileSyncSuppressed: if shouldSync:
_sync_account_skin_profile() _sync_account_skin_profile()
else:
_confirmedSkinId = normalizedId
return true return true
func apply_account_profile(profile: Dictionary) -> void: func apply_account_profile(profile: Dictionary) -> void:
@@ -240,23 +249,28 @@ func apply_account_profile(profile: Dictionary) -> void:
_clear_session_custom_skin() _clear_session_custom_skin()
var skinId := str(profile.get("skin_id", "")).strip_edges() var skinId := str(profile.get("skin_id", "")).strip_edges()
_accountAvatarUrl = _absolute_backend_url(str(profile.get("avatar_url", "")).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", []) var ownedSkinsVariant: Variant = profile.get("owned_skins", [])
if ownedSkinsVariant is Array: if ownedSkinsVariant is Array:
apply_account_skin_assets(ownedSkinsVariant as Array) apply_account_skin_assets(ownedSkinsVariant as Array)
var ownedVariant: Variant = profile.get("owned_skin_ids", []) var ownedVariant: Variant = profile.get("owned_skin_ids", [])
if ownedVariant is Array: if ownedVariant is Array:
set_owned_skin_ids(ownedVariant) 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) grant_owned_skin(skinId)
set_selected_skin(skinId, true) 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() var fallbackSkinId := _first_owned_skin_id()
if selectedSkinId != fallbackSkinId: if selectedSkinId != fallbackSkinId:
selectedSkinId = fallbackSkinId selectedSkinId = fallbackSkinId
_save() _save()
_emit_skin_changed() _emit_skin_changed()
_emit_profile_changed() _emit_profile_changed()
_confirmedSkinId = fallbackSkinId
_profileSyncSuppressed = false _profileSyncSuppressed = false
if _accountAvatarTexture != null or not _accountAvatarUrl.is_empty(): if _accountAvatarTexture != null or not _accountAvatarUrl.is_empty():
selectedAvatarId = CUSTOM_AVATAR_ID 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()) _accountAvatarUrl = _absolute_backend_url(avatarUrl.strip_edges())
_customAvatarTexture = null _customAvatarTexture = null
_customAvatarActive = false _customAvatarActive = false
_accountAvatarTexture = _texture_from_base64(avatarBase64) _accountAvatarTexture = _texture_from_base64(avatarBase64, true)
if _accountAvatarTexture != null or not _accountAvatarUrl.is_empty(): if _accountAvatarTexture != null or not _accountAvatarUrl.is_empty():
selectedAvatarId = CUSTOM_AVATAR_ID selectedAvatarId = CUSTOM_AVATAR_ID
else: else:
@@ -715,11 +729,45 @@ func _sync_account_skin_profile() -> void:
func _connect_auth_events() -> void: func _connect_auth_events() -> void:
var eventSystem := get_node_or_null("/root/EventSystem") 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 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: func _on_auth_logout(_data: Variant = null) -> void:
_pendingSkinId = ""
_confirmedSkinId = FALLBACK_SKIN_ID
clear_account_profile() clear_account_profile()
func get_profile_payload() -> Dictionary: 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.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
textureRect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE textureRect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
textureRect.stretch_mode = TextureRect.STRETCH_SCALE textureRect.stretch_mode = TextureRect.STRETCH_SCALE
textureRect.material = _create_avatar_mask_material()
textureRect.custom_minimum_size = panel.custom_minimum_size textureRect.custom_minimum_size = panel.custom_minimum_size
textureRect.set_anchors_preset(Control.PRESET_FULL_RECT) textureRect.set_anchors_preset(Control.PRESET_FULL_RECT)
panel.add_child(textureRect) panel.add_child(textureRect)
return 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: func _normalize_account_skin_asset(skin: Dictionary) -> Dictionary:
var normalized := skin.duplicate(true) var normalized := skin.duplicate(true)
var skinId := str(normalized.get("id", normalized.get("skin_id", ""))).strip_edges() 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 normalized["account_asset"] = true
return normalized 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()) var bytes := Marshalls.base64_to_raw(base64Text.strip_edges())
if bytes.is_empty(): if bytes.is_empty():
return null return null
@@ -792,6 +848,9 @@ func _texture_from_base64(base64Text: String) -> Texture2D:
var err := image.load_png_from_buffer(bytes) var err := image.load_png_from_buffer(bytes)
if err != OK: if err != OK:
return null return null
if roundAvatar:
image = _create_square_avatar_image(image)
_apply_rounded_rect_alpha(image)
return ImageTexture.create_from_image(image) return ImageTexture.create_from_image(image)
func _absolute_backend_url(path: String) -> String: func _absolute_backend_url(path: String) -> String:

View File

@@ -16,6 +16,9 @@ signal email_verification_sent(email: String)
signal email_verification_failed(message: String) signal email_verification_failed(message: String)
signal profile_update_succeeded(profile: Dictionary) signal profile_update_succeeded(profile: Dictionary)
signal profile_update_failed(message: String) 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 logout_completed()
signal browser_bootstrap_received(kind: String) 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 REQUEST_TIMEOUT: float = 12.0
const BROWSER_BOOTSTRAP_STORAGE_KEY: String = "whaletown.auth.bootstrap" const BROWSER_BOOTSTRAP_STORAGE_KEY: String = "whaletown.auth.bootstrap"
const BROWSER_BOOTSTRAP_POLL_INTERVAL: float = 0.25 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 _access_token: String = ""
var _refresh_token: String = "" var _refresh_token: String = ""
@@ -41,19 +46,27 @@ var _registration_recovery_in_flight: bool = false
var _registration_recovery_error: String = "" var _registration_recovery_error: String = ""
var _browser_bootstrap_kind: String = "" var _browser_bootstrap_kind: String = ""
var _browser_bootstrap_poll_elapsed: float = 0.0 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: func _ready() -> void:
_load_cached_session() _load_cached_session()
_try_import_browser_bootstrap(false) _try_import_browser_bootstrap(false)
if is_authenticated() and _token_needs_refresh():
call_deferred("refresh_session")
func _process(delta: float) -> void: func _process(delta: float) -> void:
if OS.get_name() != "Web" or not _browser_bootstrap_kind.is_empty(): _session_refresh_check_elapsed += delta
return if _session_refresh_check_elapsed >= SESSION_REFRESH_CHECK_INTERVAL:
_browser_bootstrap_poll_elapsed += delta _session_refresh_check_elapsed = 0.0
if _browser_bootstrap_poll_elapsed < BROWSER_BOOTSTRAP_POLL_INTERVAL: if is_authenticated() and _token_needs_refresh():
return refresh_session()
_browser_bootstrap_poll_elapsed = 0.0
_try_import_browser_bootstrap(true) 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: func _exit_tree() -> void:
for request in _active_requests: for request in _active_requests:
@@ -112,8 +125,12 @@ func login(identifier: String, password: String) -> void:
"password": password "password": password
}, _on_login_response, HTTPClient.METHOD_POST, false, true) }, _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_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(): if normalized_email.is_empty():
email_verification_failed.emit("请输入邮箱") email_verification_failed.emit("请输入邮箱")
return return
@@ -122,7 +139,8 @@ func send_email_verification(email: String) -> void:
return return
_request_json("/auth/send-email-verification", { _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: }, func(success: bool, _data: Dictionary, error_info: Dictionary) -> void:
if success: if success:
email_verification_sent.emit(normalized_email) 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", "验证码发送失败"))) 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_username := username.strip_edges()
var normalized_nickname := nickname.strip_edges() var normalized_nickname := nickname.strip_edges()
var normalized_email := email.strip_edges() var normalized_email := email.strip_edges()
var normalized_code := email_verification_code.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(): if normalized_username.is_empty():
register_failed.emit("请输入用户名") register_failed.emit("请输入用户名")
@@ -142,6 +161,9 @@ func register(username: String, password: String, nickname: String = "", email:
if not _is_valid_username(normalized_username): if not _is_valid_username(normalized_username):
register_failed.emit("用户名只能包含字母、数字和下划线,长度 1-50") register_failed.emit("用户名只能包含字母、数字和下划线,长度 1-50")
return return
if normalized_invitation_code.is_empty():
register_failed.emit("请输入邀请码")
return
if password.length() < 8: if password.length() < 8:
register_failed.emit("密码至少 8 位,并需要包含字母和数字") register_failed.emit("密码至少 8 位,并需要包含字母和数字")
return return
@@ -165,7 +187,8 @@ func register(username: String, password: String, nickname: String = "", email:
"password": password, "password": password,
"nickname": normalized_nickname, "nickname": normalized_nickname,
"email": normalized_email, "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() var normalized_skin_id := skin_id.strip_edges()
if not normalized_skin_id.is_empty(): if not normalized_skin_id.is_empty():
@@ -197,10 +220,11 @@ func update_profile(profile_data: Dictionary) -> void:
assetPayload.erase("skin_id") assetPayload.erase("skin_id")
assetPayload.erase("settings") assetPayload.erase("settings")
if profile_data.has("skin_id") and playerStateManager.has_method("update_appearance"): if profile_data.has("skin_id") and playerStateManager.has_method("update_appearance"):
var requestedSkinId := str(profile_data.get("skin_id", "")).strip_edges()
playerStateManager.call( playerStateManager.call(
"update_appearance", "update_appearance",
str(profile_data.get("skin_id", "")), requestedSkinId,
Callable(self, "_on_profile_response") Callable(self, "_on_appearance_profile_response").bind(requestedSkinId)
) )
if profile_data.has("settings") and playerStateManager.has_method("update_settings"): if profile_data.has("settings") and playerStateManager.has_method("update_settings"):
var settingsVariant: Variant = profile_data.get("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"): if not profile_data.has("skin_id") and not profile_data.has("settings"):
profile_update_failed.emit("没有可保存的玩家资料") profile_update_failed.emit("没有可保存的玩家资料")
func refresh_session() -> void: func refresh_session() -> bool:
if _refresh_token.strip_edges().is_empty() or _refresh_in_flight: if _refresh_token.strip_edges().is_empty():
return return false
if _refresh_in_flight:
return true
_refresh_in_flight = true _refresh_in_flight = true
_request_json("/auth/refresh-token", { _request_json("/auth/refresh-token", {
"refresh_token": _refresh_token "refresh_token": _refresh_token
}, _on_refresh_response, HTTPClient.METHOD_POST, false, true) }, _on_refresh_response, HTTPClient.METHOD_POST, false, true)
return true
func logout() -> void: func logout() -> void:
_clear_session_memory() _clear_session_memory()
@@ -388,10 +415,12 @@ func _clear_pending_registration() -> void:
func _on_refresh_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void: func _on_refresh_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void:
_refresh_in_flight = false _refresh_in_flight = false
if not success: if not success:
session_refresh_completed.emit(false)
logout() logout()
return return
_apply_auth_payload(data) _apply_auth_payload(data)
session_refresh_completed.emit(true)
auth_state_changed.emit(true, get_current_user()) auth_state_changed.emit(true, get_current_user())
_refresh_player_snapshot() _refresh_player_snapshot()
@@ -403,6 +432,11 @@ func _apply_auth_payload(payload: Dictionary) -> void:
var auth_data: Dictionary = data_variant var auth_data: Dictionary = data_variant
_access_token = str(auth_data.get("access_token", "")).strip_edges() _access_token = str(auth_data.get("access_token", "")).strip_edges()
_refresh_token = str(auth_data.get("refresh_token", _refresh_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", {}) var user_variant: Variant = auth_data.get("user", {})
if user_variant is Dictionary: 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()) profile_update_succeeded.emit(get_current_profile())
auth_state_changed.emit(is_authenticated(), get_current_user()) 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: func _on_profile_assets_response(success: bool, data: Dictionary, _error_info: Dictionary, requestGeneration: int) -> void:
if requestGeneration != _account_generation: if requestGeneration != _account_generation:
return 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() _refresh_token = str(config.get_value("auth", "refresh_token", "")).strip_edges()
_access_token = str(config.get_value("auth", "access_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_user.clear()
_current_profile.clear() _current_profile.clear()
@@ -554,6 +619,7 @@ func _emit_cached_auth_state() -> void:
func _clear_session_memory() -> void: func _clear_session_memory() -> void:
_access_token = "" _access_token = ""
_refresh_token = "" _refresh_token = ""
_access_token_expires_at = 0.0
_current_user.clear() _current_user.clear()
_current_profile.clear() _current_profile.clear()
_advance_account_generation() _advance_account_generation()
@@ -610,6 +676,26 @@ func _password_has_letter_and_number(password: String) -> bool:
has_number = true has_number = true
return has_letter and has_number 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: func _http_result_to_string(result: int) -> String:
match result: match result:
HTTPRequest.RESULT_SUCCESS: HTTPRequest.RESULT_SUCCESS:

View File

@@ -66,11 +66,13 @@ const CHAT_WEBSOCKET_MANAGER_SCRIPT: Script = preload("res://_Core/managers/WebS
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd") const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
# WebSocket 服务器 URL原生 WebSocket # 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_MAX_ATTEMPTS: int = 5
const RECONNECT_BASE_DELAY: float = 3.0 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 const RATE_LIMIT_MESSAGES: int = 10
@@ -98,7 +100,9 @@ const CHAT_ERROR_MESSAGES: Dictionary = {
"PERMISSION_DENIED": "您没有权限发送消息", "PERMISSION_DENIED": "您没有权限发送消息",
"SESSION_EXPIRED": "会话已过期,请重新连接", "SESSION_EXPIRED": "会话已过期,请重新连接",
"ZULIP_ERROR": "消息服务暂时不可用", "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_username: String = ""
var _current_user_id: String = ""
var _current_map: String = "" var _current_map: String = ""
var _world_ready_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 # 游戏 token
var _game_token: String = "" var _game_token: String = ""
var _guest_mode: bool = false
# 连接/登录完成前暂存用户已经提交的消息,避免刚进游戏时第一条消息被丢掉。 # 连接/登录完成前暂存用户已经提交的消息,避免刚进游戏时第一条消息被丢掉。
var _queued_messages: Array[Dictionary] = [] var _queued_messages: Array[Dictionary] = []
@@ -164,6 +175,10 @@ func _ready() -> void:
_history_request.timeout = 12.0 _history_request.timeout = 12.0
_history_request.request_completed.connect(_on_history_request_completed) _history_request.request_completed.connect(_on_history_request_completed)
add_child(_history_request) 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() _connect_signals()
@@ -209,13 +224,24 @@ func set_game_token(token: String) -> void:
_queued_messages.clear() _queued_messages.clear()
_recoverable_messages.clear() _recoverable_messages.clear()
_world_ready_map = "" _world_ready_map = ""
_world_ready_confirmed = false
_clear_pending_appearance_broadcast()
func is_logged_in() -> bool: func is_logged_in() -> bool:
return _is_logged_in 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() 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 return
var payload := { var payload := {
@@ -223,9 +249,13 @@ func mark_world_ready(mapId: String, position: Vector2) -> void:
"mapId": normalizedMapId, "mapId": normalizedMapId,
"x": position.x, "x": position.x,
"y": position.y, "y": position.y,
"direction": direction,
"movementState": movementState,
"sequence": sequence,
} }
if _websocket_manager.send_message(JSON.stringify(payload)) == OK: if _websocket_manager.send_message(JSON.stringify(payload)) == OK:
_world_ready_map = normalizedMapId _world_ready_map = normalizedMapId
_world_ready_confirmed = false
func leave_world(sceneId: String = "private_space") -> void: func leave_world(sceneId: String = "private_space") -> void:
if not _is_logged_in or _world_ready_map.is_empty(): 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: if _websocket_manager.send_message(JSON.stringify(payload)) == OK:
_world_ready_map = "" _world_ready_map = ""
_world_ready_confirmed = false
_stop_appearance_broadcast_retry()
func notify_appearance_changed() -> void: func notify_appearance_changed(skinId: String = "") -> void:
if not _is_logged_in or _world_ready_map.is_empty(): 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 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"})) _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 # 获取游戏 token
# #
@@ -267,12 +346,12 @@ func connect_to_chat_server() -> void:
push_warning("聊天服务器正在连接") push_warning("聊天服务器正在连接")
return 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() var authToken := _get_auth_manager_token()
if not authToken.is_empty(): if not authToken.is_empty():
_game_token = authToken _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", "请先登录后再使用聊天") _handle_error("AUTH_FAILED", "请先登录后再使用聊天")
return return
@@ -291,6 +370,7 @@ func disconnect_from_chat_server() -> void:
# 断开连接 # 断开连接
_websocket_manager.disconnect_websocket() _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: 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)}) 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: 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": if not _settings_bool("allow_nearby_private", true) and private_context.strip_edges() == "whisper":
_handle_error("PRIVATE_DISABLED", "附近私聊已在设置中关闭") _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 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 suppress_local_echo := bool(extra_data.get("_suppressLocalEcho", false))
var is_recovery_retry := bool(extra_data.get("_isRecoveryRetry", 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) var recoverable_extra_data := extra_data.duplicate(true)
recoverable_extra_data.erase("_suppressLocalEcho") 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: if send_err != OK:
_handle_error("SEND_FAILED", "WebSocket send failed: %s" % error_string(send_err)) _handle_error("SEND_FAILED", "WebSocket send failed: %s" % error_string(send_err))
return false return false
_track_recoverable_message({ if not non_recoverable:
"content": normalized_content, _track_recoverable_message({
"scope": scope, "content": normalized_content,
"extra_data": recoverable_extra_data "scope": scope,
}) "extra_data": recoverable_extra_data
})
# 记录发送时间 # 记录发送时间
if not is_recovery_retry: 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") # ChatManager.update_player_position(150.0, 200.0, "novice_village")
func update_player_position(x: float, y: float, map_id: String) -> void: 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, {}) 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(): if not _websocket_manager.is_websocket_connected():
connect_to_chat_server() connect_to_chat_server()
return return
@@ -589,7 +716,10 @@ func update_player_position_with_appearance(x: float, y: float, map_id: String,
"type": "position", "type": "position",
"x": x, "x": x,
"y": y, "y": y,
"mapId": map_id "mapId": map_id,
"direction": direction,
"movementState": movementState,
"sequence": sequence
} }
# 发送消息JSON 字符串) # 发送消息JSON 字符串)
var json_string := JSON.stringify(position_data) 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 - 是否可以发送 # bool - 是否可以发送
func can_send_message() -> bool: func can_send_message() -> bool:
if _guest_mode:
return false
var current_time := Time.get_unix_time_from_system() var current_time := Time.get_unix_time_from_system()
# 清理过期的时间戳 # 清理过期的时间戳
@@ -794,6 +926,8 @@ func _connect_signals() -> void:
func _on_connection_lost() -> void: func _on_connection_lost() -> void:
_is_logged_in = false _is_logged_in = false
_world_ready_map = "" _world_ready_map = ""
_world_ready_confirmed = false
_stop_appearance_broadcast_retry()
_restore_recoverable_messages_to_queue() _restore_recoverable_messages_to_queue()
func _load_token_from_environment() -> void: func _load_token_from_environment() -> void:
@@ -911,6 +1045,9 @@ func _preview_text(text: String) -> String:
# 发送登录消息 # 发送登录消息
func _send_login_message() -> void: 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(): if _game_token.strip_edges().is_empty():
_handle_error("AUTH_FAILED", "缺少聊天认证 token") _handle_error("AUTH_FAILED", "缺少聊天认证 token")
return return
@@ -967,8 +1104,12 @@ func _on_data_received(message: String) -> void:
match message_type: match message_type:
"connected": "connected":
pass pass
"pong":
pass
"login_success": "login_success":
_handle_login_success(data) _handle_login_success(data)
"guest_login_success":
_handle_guest_login_success(data)
"login_error": "login_error":
_handle_login_error(data) _handle_login_error(data)
"chat": "chat":
@@ -997,6 +1138,50 @@ func _on_data_received(message: String) -> void:
_handle_friend_error(data) _handle_friend_error(data)
"map_players_snapshot": "map_players_snapshot":
_handle_map_players_snapshot(data) _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": "system_presence":
_handle_system_presence(data) _handle_system_presence(data)
"position_update": "position_update":
@@ -1006,9 +1191,13 @@ func _on_data_received(message: String) -> void:
"appearance_changed": "appearance_changed":
_handle_player_joined(data) _handle_player_joined(data)
"world_ready_success": "world_ready_success":
pass _handle_world_ready_success(data)
"appearance_changed_success":
_handle_appearance_changed_success(data)
"world_left": "world_left":
_world_ready_map = "" _world_ready_map = ""
_world_ready_confirmed = false
_stop_appearance_broadcast_retry()
"player_left": "player_left":
_handle_player_left(data) _handle_player_left(data)
"position_updated": "position_updated":
@@ -1031,13 +1220,17 @@ func _warn_empty_message_type_limited(data: Dictionary) -> void:
# 处理登录成功 # 处理登录成功
func _handle_login_success(data: Dictionary) -> void: func _handle_login_success(data: Dictionary) -> void:
_guest_mode = false
_is_logged_in = true _is_logged_in = true
_refreshing_auth_session = false _refreshing_auth_session = false
_chat_login_refresh_attempted = false _chat_login_refresh_attempted = false
_friend_protocol_supported = true _friend_protocol_supported = true
_current_username = data.get("username", "") _current_username = data.get("username", "")
_current_user_id = str(data.get("userId", data.get("user_id", "")))
_current_map = data.get("currentMap", "") _current_map = data.get("currentMap", "")
_world_ready_map = "" _world_ready_map = ""
_world_ready_confirmed = false
_stop_appearance_broadcast_retry()
# 重置当前会话缓存(每次登录/重连都清空,重新开始接收消息) # 重置当前会话缓存(每次登录/重连都清空,重新开始接收消息)
reset_session() reset_session()
@@ -1051,6 +1244,18 @@ func _handle_login_success(data: Dictionary) -> void:
_flush_queued_messages() _flush_queued_messages()
request_friend_list() 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: func _handle_system_presence(data: Dictionary) -> void:
var content := str(data.get("content", "")).strip_edges() var content := str(data.get("content", "")).strip_edges()
if content.is_empty(): if content.is_empty():
@@ -1070,8 +1275,8 @@ func _handle_system_presence(data: Dictionary) -> void:
chat_message_received.emit("系统", content, false, timestamp) chat_message_received.emit("系统", content, false, timestamp)
_emit_event(EventNames.CHAT_MESSAGE_RECEIVED, message) _emit_event(EventNames.CHAT_MESSAGE_RECEIVED, message)
func _on_account_profile_updated(_profile: Dictionary) -> void: func _on_account_profile_updated(profile: Dictionary) -> void:
notify_appearance_changed() notify_appearance_changed(str(profile.get("skin_id", "")))
func _on_auth_state_changed(isAuthenticated: bool, _user: Dictionary) -> void: func _on_auth_state_changed(isAuthenticated: bool, _user: Dictionary) -> void:
if not isAuthenticated: if not isAuthenticated:
@@ -1111,6 +1316,19 @@ func _handle_login_error(data: Dictionary) -> void:
func _handle_chat_sent(data: Dictionary) -> void: func _handle_chat_sent(data: Dictionary) -> void:
var message_id: String = str(data.get("messageId", "")) var message_id: String = str(data.get("messageId", ""))
var timestamp: float = data.get("timestamp", 0.0) 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) chat_message_sent.emit(message_id, timestamp)
@@ -1118,24 +1336,28 @@ func _handle_chat_sent(data: Dictionary) -> void:
# 通过 EventSystem 广播Signal Up # 通过 EventSystem 广播Signal Up
_emit_event(EventNames.CHAT_MESSAGE_SENT, { _emit_event(EventNames.CHAT_MESSAGE_SENT, {
"message_id": message_id, "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() _recoverable_messages.pop_front()
# 处理聊天消息发送失败 # 处理聊天消息发送失败
func _handle_chat_error(data: Dictionary) -> void: func _handle_chat_error(data: Dictionary) -> void:
var error_message: String = data.get("message", "消息发送失败") var error_message: String = data.get("message", "消息发送失败")
var error_code: String = str(data.get("code", data.get("error_code", "CHAT_SEND_FAILED"))).strip_edges() var error_code: String = str(data.get("code", data.get("error_code", "CHAT_SEND_FAILED"))).strip_edges()
if error_code == "SESSION_EXPIRED": var is_world_bulletin: bool = bool(data.get("worldBulletin", data.get("world_bulletin", false)))
_handle_session_expired(error_message)
return
# 通过 EventSystem 广播错误Signal Up # 通过 EventSystem 广播错误Signal Up
_emit_event(EventNames.CHAT_ERROR_OCCURRED, { _emit_event(EventNames.CHAT_ERROR_OCCURRED, {
"error_code": error_code if not error_code.is_empty() else "CHAT_SEND_FAILED", "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: func _handle_friend_list(data: Dictionary) -> void:
_friends.clear() _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 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 private_context: String = str(data.get("privateContext", data.get("private_context", ""))).strip_edges()
var is_private: bool = scope == "private" var is_private: bool = scope == "private"
var 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)) 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_user_id": to_user_id,
"to_username": to_username, "to_username": to_username,
"private_context": private_context, "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_user_id": to_user_id,
"to_username": to_username, "to_username": to_username,
"private_context": private_context, "private_context": private_context,
"is_private": is_private "is_private": is_private,
"world_bulletin": is_world_bulletin
}) })
# 解析聊天消息时间戳(兼容 unix 秒 / ISO 8601 字符串) # 解析聊天消息时间戳(兼容 unix 秒 / ISO 8601 字符串)
@@ -1359,13 +1584,16 @@ func _handle_position_update(data: Dictionary) -> void:
"userId": user_id, "userId": user_id,
"username": str(data.get("username", "")), "username": str(data.get("username", "")),
"position": Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))), "position": Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))),
"mapId": str(data.get("mapId", data.get("map_id", ""))), "direction": str(data.get("direction", "down")),
"skin_id": str(appearance.get("skin_id", "")), "movement_state": str(data.get("movementState", data.get("movement_state", "walk"))),
"avatar_id": str(appearance.get("avatar_id", "")), "sequence": int(data.get("sequence", -1)),
"skin_asset": appearance.get("skin_asset", {}), "mapId": str(data.get("mapId", data.get("map_id", ""))),
"cafe_companion": _normalize_cafe_companion(data.get("cafeCompanion", data.get("cafe_companion", null))), "skin_id": str(appearance.get("skin_id", "")),
"movement_locked": bool(data.get("movementLocked", data.get("movement_locked", false))) "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: func _handle_map_players_snapshot(data: Dictionary) -> void:
var players: Array[Dictionary] = [] var players: Array[Dictionary] = []
@@ -1383,19 +1611,88 @@ func _handle_map_players_snapshot(data: Dictionary) -> void:
"userId": user_id, "userId": user_id,
"username": str(player.get("username", "")), "username": str(player.get("username", "")),
"position": Vector2(float(player.get("x", 0.0)), float(player.get("y", 0.0))), "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", ""))))), "direction": str(player.get("direction", "down")),
"skin_id": str(appearance.get("skin_id", "")), "movement_state": str(player.get("movementState", player.get("movement_state", "idle"))),
"avatar_id": str(appearance.get("avatar_id", "")), "sequence": int(player.get("sequence", -1)),
"skin_asset": appearance.get("skin_asset", {}), "mapId": str(player.get("mapId", player.get("map_id", data.get("mapId", data.get("map_id", ""))))),
"cafe_companion": _normalize_cafe_companion(player.get("cafeCompanion", player.get("cafe_companion", null))), "skin_id": str(appearance.get("skin_id", "")),
"movement_locked": bool(player.get("movementLocked", player.get("movement_locked", false))) "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, { _emit_event(EventNames.REMOTE_PLAYERS_SNAPSHOT_READY, {
"mapId": str(data.get("mapId", data.get("map_id", ""))), "mapId": str(data.get("mapId", data.get("map_id", ""))),
"players": players "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: func _handle_player_joined(data: Dictionary) -> void:
var user_id := str(data.get("userId", data.get("user_id", ""))).strip_edges() var user_id := str(data.get("userId", data.get("user_id", ""))).strip_edges()
if user_id.is_empty(): if user_id.is_empty():
@@ -1406,19 +1703,25 @@ func _handle_player_joined(data: Dictionary) -> void:
"userId": user_id, "userId": user_id,
"username": str(data.get("username", "")), "username": str(data.get("username", "")),
"position": Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))), "position": Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))),
"mapId": str(data.get("mapId", data.get("map_id", ""))), "direction": str(data.get("direction", "down")),
"skin_id": str(appearance.get("skin_id", "")), "movement_state": str(data.get("movementState", data.get("movement_state", "idle"))),
"avatar_id": str(appearance.get("avatar_id", "")), "sequence": int(data.get("sequence", -1)),
"skin_asset": appearance.get("skin_asset", {}), "mapId": str(data.get("mapId", data.get("map_id", ""))),
"cafe_companion": _normalize_cafe_companion(data.get("cafeCompanion", data.get("cafe_companion", null))), "skin_id": str(appearance.get("skin_id", "")),
"movement_locked": bool(data.get("movementLocked", data.get("movement_locked", false))) "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: func _normalize_player_appearance(data: Dictionary) -> Dictionary:
var appearanceVariant: Variant = data.get("appearance", {}) var appearanceVariant: Variant = data.get("appearance", {})
var appearance: Dictionary = appearanceVariant if appearanceVariant is Dictionary else {} 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 { 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", ""))))), "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", {})))), "skin_asset": data.get("skinAsset", data.get("skin_asset", appearance.get("skinAsset", appearance.get("skin_asset", {})))),
} }

View File

@@ -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

View File

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

View File

@@ -32,6 +32,7 @@ signal scene_changed(scene_name: String)
signal scene_change_started(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_progress(scene_name: String, downloaded_bytes: int, total_bytes: int)
signal scene_pack_failed(scene_name: String, message: String) 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 _next_spawn_name: String = "" # 下一个场景的出生点名称 (String)
var _pack_manifest: Dictionary = {} var _pack_manifest: Dictionary = {}
var _loaded_scene_packs: 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_request: HTTPRequest
var _active_pack_scene_name: String = "" var _active_pack_scene_name: String = ""
var _active_pack_expected_size: int = 0
var _pack_overlay: CanvasLayer var _pack_overlay: CanvasLayer
var _pack_status_label: Label var _pack_status_label: Label
var _pack_progress_bar: ProgressBar var _pack_progress_bar: ProgressBar
var _web_pack_progress_last_emit_msec: int = 0 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 PACK_CACHE_DIR: String = "user://scene-packs"
const SCENE_PACK_KEYS: Dictionary = { const SCENE_PACK_KEYS: Dictionary = {
"main": "auth", "main": "auth",
@@ -61,6 +66,14 @@ const SCENE_PACK_KEYS: Dictionary = {
"personal_space": "personal_space", "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 current_scene_name = scene_name
is_changing_scene = false is_changing_scene = false
scene_changed.emit(scene_name) scene_changed.emit(scene_name)
_schedule_scene_prefetch(scene_name)
# 隐藏过渡效果 # 隐藏过渡效果
if use_transition: if use_transition:
@@ -154,13 +168,15 @@ func _process(_delta: float) -> void:
return return
var downloaded := _active_pack_request.get_downloaded_bytes() var downloaded := _active_pack_request.get_downloaded_bytes()
var total := _active_pack_request.get_body_size() var total := _active_pack_request.get_body_size()
if total <= 0:
total = _active_pack_expected_size
if is_instance_valid(_pack_progress_bar): if is_instance_valid(_pack_progress_bar):
_pack_progress_bar.indeterminate = total <= 0 _pack_progress_bar.indeterminate = total <= 0
if total > 0: if total > 0:
_pack_progress_bar.max_value = total _pack_progress_bar.max_value = total
_pack_progress_bar.value = downloaded _pack_progress_bar.value = downloaded
if is_instance_valid(_pack_status_label): 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) _notify_web_auth_pack_progress(downloaded, total)
scene_pack_progress.emit(_active_pack_scene_name, 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)): if packKey.is_empty() or bool(_loaded_scene_packs.get(packKey, false)):
return true return true
_show_pack_overlay("正在准备场景...") _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(): if _pack_manifest.is_empty():
_pack_manifest = await _download_pack_manifest() _pack_manifest = await _download_pack_manifest()
if _pack_manifest.is_empty(): if _pack_manifest.is_empty():
_show_pack_error(scene_name, "场景清单加载失败,请检查网络后重试")
return false return false
var entryVariant: Variant = _pack_manifest.get(packKey, {}) var entryVariant: Variant = _pack_manifest.get(pack_key, {})
if not (entryVariant is Dictionary): if not (entryVariant is Dictionary):
_show_pack_error(scene_name, "服务器缺少场景资源:%s" % packKey)
return false return false
var entry := entryVariant as Dictionary var entry := entryVariant as Dictionary
var fileName := str(entry.get("file", "")).get_file() var fileName := str(entry.get("file", "")).get_file()
if fileName.is_empty() or not fileName.ends_with(".pck"): if fileName.is_empty() or not fileName.ends_with(".pck"):
_show_pack_error(scene_name, "场景资源清单格式错误")
return false return false
var expectedSize := int(entry.get("size", 0)) var expectedSize := int(entry.get("size", 0))
var cacheDirAbsolute := ProjectSettings.globalize_path(PACK_CACHE_DIR) var cacheDirAbsolute := ProjectSettings.globalize_path(PACK_CACHE_DIR)
DirAccess.make_dir_recursive_absolute(cacheDirAbsolute) DirAccess.make_dir_recursive_absolute(cacheDirAbsolute)
var localPath := "%s/%s" % [PACK_CACHE_DIR, fileName] var localPath := "%s/%s" % [PACK_CACHE_DIR, fileName]
if _is_scene_pack_file_valid(localPath, fileName, expectedSize) and ProjectSettings.load_resource_pack(localPath, true): 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 return true
if FileAccess.file_exists(localPath): if FileAccess.file_exists(localPath):
DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath)) DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath))
var packUrl := _resolve_web_url("/packs/%s" % fileName) var packUrl := _resolve_web_url("packs/%s" % fileName)
if packUrl.is_empty() or not await _download_pack(scene_name, packUrl, localPath): if packUrl.is_empty() or not await _download_pack(scene_name, packUrl, localPath, expectedSize):
_show_pack_error(scene_name, "场景下载失败,请检查网络后重试")
return false return false
if not _is_scene_pack_file_valid(localPath, fileName, expectedSize): if not _is_scene_pack_file_valid(localPath, fileName, expectedSize):
DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath)) DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath))
_show_pack_error(scene_name, "场景资源下载不完整,请重新进入")
return false return false
if not ProjectSettings.load_resource_pack(localPath, true): if not ProjectSettings.load_resource_pack(localPath, true):
DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath)) DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath))
_show_pack_error(scene_name, "场景资源损坏,请重新进入")
return false return false
_loaded_scene_packs[packKey] = true
_hide_pack_overlay()
return true return true
func _is_scene_pack_file_valid(local_path: String, file_name: String, expected_size: int) -> bool: 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", {}) var packsVariant: Variant = root.get("packs", {})
return packsVariant as Dictionary if packsVariant is Dictionary else {} 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() var request := HTTPRequest.new()
request.timeout = 180.0 request.timeout = 900.0
request.download_file = local_path request.download_file = local_path
add_child(request) add_child(request)
_active_pack_request = request _active_pack_request = request
_active_pack_scene_name = scene_name _active_pack_scene_name = scene_name
_active_pack_expected_size = expected_size
if request.request(url) != OK: if request.request(url) != OK:
_active_pack_request = null _active_pack_request = null
_active_pack_scene_name = "" _active_pack_scene_name = ""
_active_pack_expected_size = 0
request.queue_free() request.queue_free()
return false return false
var response: Array = await request.request_completed var response: Array = await request.request_completed
_active_pack_request = null _active_pack_request = null
_active_pack_scene_name = "" _active_pack_scene_name = ""
_active_pack_expected_size = 0
request.queue_free() request.queue_free()
if response.size() < 2 or int(response[0]) != HTTPRequest.RESULT_SUCCESS: if response.size() < 2 or int(response[0]) != HTTPRequest.RESULT_SUCCESS:
return false return false
@@ -343,6 +423,12 @@ func _format_download_progress(downloaded: int, total: int) -> String:
return "%.1f MB" % downloadedMb return "%.1f MB" % downloadedMb
return "%.1f / %.1f MB" % [downloadedMb, float(total) / 1048576.0] 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)]
# ============ 查询方法 ============ # ============ 查询方法 ============
# 获取当前场景名称 # 获取当前场景名称

View File

@@ -69,7 +69,7 @@ enum ConnectionState {
# ============================================================================ # ============================================================================
# WebSocket 服务器 URL原生 WebSocket # 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 const DEFAULT_MAX_RECONNECT_ATTEMPTS: int = 5
@@ -119,6 +119,9 @@ var _heartbeat_timer: Timer
# 心跳间隔(秒) # 心跳间隔(秒)
const HEARTBEAT_INTERVAL: float = 30.0 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: while _websocket_peer.get_available_packet_count() > 0:
var packet: PackedByteArray = _websocket_peer.get_packet() var packet: PackedByteArray = _websocket_peer.get_packet()
var message: String = packet.get_string_from_utf8() var message: String = packet.get_string_from_utf8()
_last_server_message_at_msec = Time.get_ticks_msec()
# 发射消息接收信号 # 发射消息接收信号
data_received.emit(message) data_received.emit(message)
@@ -194,9 +198,6 @@ func connect_to_game_server(is_reconnect_attempt: bool = false) -> void:
_set_connection_state(ConnectionState.ERROR) _set_connection_state(ConnectionState.ERROR)
return return
# 启动心跳
_start_heartbeat()
# 断开 WebSocket 连接 # 断开 WebSocket 连接
func disconnect_websocket() -> void: func disconnect_websocket() -> void:
_disconnect() _disconnect()
@@ -358,9 +359,12 @@ func _on_websocket_connected() -> void:
reconnection_succeeded.emit() reconnection_succeeded.emit()
_set_connection_state(ConnectionState.CONNECTED) _set_connection_state(ConnectionState.CONNECTED)
_last_server_message_at_msec = Time.get_ticks_msec()
_start_heartbeat()
# WebSocket 连接关闭处理 # WebSocket 连接关闭处理
func _on_websocket_closed() -> void: func _on_websocket_closed() -> void:
_stop_heartbeat()
if not _manual_disconnect_requested and _auto_reconnect_enabled: if not _manual_disconnect_requested and _auto_reconnect_enabled:
connection_lost.emit() connection_lost.emit()
_attempt_reconnect() _attempt_reconnect()
@@ -382,13 +386,6 @@ func _setup_reconnect_timer() -> void:
# 尝试重连 # 尝试重连
func _attempt_reconnect() -> 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 _reconnect_attempt += 1
_set_connection_state(ConnectionState.RECONNECTING) _set_connection_state(ConnectionState.RECONNECTING)
@@ -401,7 +398,8 @@ func _attempt_reconnect() -> void:
# 计算重连延迟(指数退避) # 计算重连延迟(指数退避)
func _calculate_reconnect_delay() -> float: func _calculate_reconnect_delay() -> float:
# 指数退避: base_delay * 2^(attempt-1) # 指数退避: 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) return min(delay, MAX_RECONNECT_DELAY)
@@ -434,9 +432,16 @@ func _stop_heartbeat() -> void:
# 心跳超时处理 # 心跳超时处理
func _on_heartbeat() -> void: func _on_heartbeat() -> void:
# 不发送心跳,避免服务器返回 "消息格式错误" if _websocket_peer.get_ready_state() != WebSocketPeer.STATE_OPEN:
# 如果需要心跳,服务器应该支持特定格式 return
pass 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: ConnectionState.CONNECTED:
return "已连接" return "已连接"
ConnectionState.RECONNECTING: ConnectionState.RECONNECTING:
return "重连中 (%d/%d)" % [_reconnect_attempt, _max_reconnect_attempts] return "重连中 (%d)" % _reconnect_attempt
ConnectionState.ERROR: ConnectionState.ERROR:
return "错误" return "错误"
_: _:

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 KiB

After

Width:  |  Height:  |  Size: 576 KiB

View File

@@ -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.

View File

@@ -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={}

View File

@@ -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={}

View File

@@ -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;
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -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

24
deploy/Caddyfile.example Normal file
View File

@@ -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
}
}

129
deploy/Caddyfile.production Normal file
View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -1,6 +1,6 @@
server { server {
listen 80; listen 80;
server_name whaletown.xinghangee.icu; server_name whaletown.novamailio.com;
root /var/www/whale-town-front-v2/build/web; root /var/www/whale-town-front-v2/build/web;
index index.html; index index.html;

View File

@@ -8,7 +8,7 @@ dedicated_server=false
custom_features="" custom_features=""
export_filter="scenes" export_filter="scenes"
export_files=PackedStringArray("res://scenes/ui/WebBootstrap.tscn") 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="" exclude_filter=""
export_path="build/web/index.html" export_path="build/web/index.html"
patches=PackedStringArray() patches=PackedStringArray()
@@ -53,7 +53,7 @@ dedicated_server=false
custom_features="" custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="" include_filter=""
exclude_filter="" exclude_filter="build/**,output/**,docs/**,scripts/**,tools/**"
export_path="build/linux/WhaleTown-V2.x86_64" export_path="build/linux/WhaleTown-V2.x86_64"
patches=PackedStringArray() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
@@ -84,7 +84,7 @@ dedicated_server=false
custom_features="" custom_features=""
export_filter="scenes" export_filter="scenes"
export_files=PackedStringArray("res://scenes/Maps/square.tscn") 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" exclude_filter="assets/maps/work_zone/**/*.png,assets/maps/personal_space/**/*.png,assets/maps/cafe/**/*.png"
export_path="build/packs/square.pck" export_path="build/packs/square.pck"
patches=PackedStringArray() patches=PackedStringArray()
@@ -112,7 +112,7 @@ dedicated_server=false
custom_features="" custom_features=""
export_filter="scenes" export_filter="scenes"
export_files=PackedStringArray("res://scenes/Maps/work_zone.tscn") 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" exclude_filter="assets/maps/square/**/*.png,assets/maps/personal_space/**/*.png,assets/maps/cafe/**/*.png"
export_path="build/packs/work_zone.pck" export_path="build/packs/work_zone.pck"
patches=PackedStringArray() patches=PackedStringArray()
@@ -140,7 +140,7 @@ dedicated_server=false
custom_features="" custom_features=""
export_filter="scenes" export_filter="scenes"
export_files=PackedStringArray("res://scenes/Maps/cafe_interior.tscn") 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" exclude_filter="assets/maps/square/**/*.png,assets/maps/work_zone/**/*.png,assets/maps/personal_space/**/*.png"
export_path="build/packs/cafe_interior.pck" export_path="build/packs/cafe_interior.pck"
patches=PackedStringArray() patches=PackedStringArray()
@@ -168,7 +168,7 @@ dedicated_server=false
custom_features="" custom_features=""
export_filter="scenes" export_filter="scenes"
export_files=PackedStringArray("res://scenes/Maps/personal_space.tscn") 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" exclude_filter="assets/maps/square/**/*.png,assets/maps/work_zone/**/*.png,assets/maps/cafe/**/*.png"
export_path="build/packs/personal_space.pck" export_path="build/packs/personal_space.pck"
patches=PackedStringArray() patches=PackedStringArray()
@@ -213,3 +213,78 @@ variant/extensions_support=false
variant/thread_support=false variant/thread_support=false
vram_texture_compression/for_desktop=true vram_texture_compression/for_desktop=true
vram_texture_compression/for_mobile=false 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"

View File

@@ -14,7 +14,8 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
[application] [application]
config/name="WhaleTown V2" config/name="WhaleTown"
config/version="1.0.0"
run/main_scene="res://scenes/ui/WebBootstrap.tscn" run/main_scene="res://scenes/ui/WebBootstrap.tscn"
config/features=PackedStringArray("4.6", "GL Compatibility") config/features=PackedStringArray("4.6", "GL Compatibility")
config/icon="res://icon.svg" config/icon="res://icon.svg"
@@ -33,6 +34,7 @@ CafeCompanionManager="*res://_Core/managers/CafeCompanionManager.gd"
AppearanceManager="*res://_Core/managers/AppearanceManager.gd" AppearanceManager="*res://_Core/managers/AppearanceManager.gd"
SettingsManager="*res://_Core/managers/SettingsManager.gd" SettingsManager="*res://_Core/managers/SettingsManager.gd"
NotificationSoundManager="*res://_Core/managers/NotificationSoundManager.gd" NotificationSoundManager="*res://_Core/managers/NotificationSoundManager.gd"
InputFocusManager="*res://_Core/managers/InputFocusManager.gd"
[display] [display]

View File

@@ -16,11 +16,12 @@ const WORK_ZONE_CAFE_RETURN_POSITION: Vector2 = Vector2(-1093, 560)
const CAFE_DOOR_POSITION: Vector2 = Vector2(0, 392) const CAFE_DOOR_POSITION: Vector2 = Vector2(0, 392)
const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres") const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres")
const COMPANION_NAMEPLATE_RENDER_SCALE: float = 0.5 const COMPANION_NAMEPLATE_RENDER_SCALE: float = 0.5
const COMPANION_NAMEPLATE_FONT_SIZE: int = 24 const COMPANION_NAMEPLATE_FONT_SIZE: int = 16
const COMPANION_NAMEPLATE_VISUAL_HEIGHT: int = 22 const COMPANION_NAMEPLATE_VISUAL_HEIGHT: int = 15
const COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH: int = 76 const COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH: int = 64
const COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH: int = 118 const COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH: int = 100
const COMPANION_NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12 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 NPC_NAMEPLATE_OFFSET_Y: float = -136.0
const HIRED_PLAYER_NAMEPLATE_OFFSET_Y: float = -96.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 _isChangingScene: bool = false
var _lastRecruitmentClickMsec: int = 0 var _lastRecruitmentClickMsec: int = 0
var _companionNicknameFont: FontFile
func _ready() -> void: func _ready() -> void:
_align_service_occupants() _align_service_occupants()
@@ -252,34 +254,20 @@ func _configure_companion_nameplate(label: Label, personaName: String, offsetY:
label.clip_text = true label.clip_text = true
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
label.mouse_filter = Control.MOUSE_FILTER_IGNORE 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_color", Color(0.09, 0.25, 0.31, 1.0))
label.add_theme_color_override("font_shadow_color", Color(1.0, 1.0, 1.0, 0.85)) label.add_theme_color_override("font_outline_color", Color(1.0, 0.965, 0.88, 0.98))
label.add_theme_constant_override("shadow_offset_x", 0) label.add_theme_constant_override("outline_size", 3)
label.add_theme_constant_override("shadow_offset_y", 1) 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_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: func _companion_nameplate_visual_width(displayName: String) -> int:
var estimatedWidth := displayName.length() * COMPANION_NAMEPLATE_VISUAL_CHAR_WIDTH + 28 var measuredWidth := _get_companion_nickname_font().get_string_size(displayName, HORIZONTAL_ALIGNMENT_LEFT, -1, COMPANION_NAMEPLATE_FONT_SIZE).x
return clampi(estimatedWidth, COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH, COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH) 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: func _get_companion_nickname_font() -> FontFile:
var style := StyleBoxFlat.new() if _companionNicknameFont == null:
style.bg_color = Color(1.0, 0.988, 0.955, 0.96) _companionNicknameFont = COMPANION_NAMEPLATE_FONT_ZH.duplicate() as FontFile
style.border_color = Color(0.18, 0.34, 0.38, 0.92) _companionNicknameFont.fallbacks = [COMPANION_NAMEPLATE_FONT_LATIN]
style.border_width_left = 2 return _companionNicknameFont
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

View File

@@ -7,6 +7,7 @@ extends Node
# ============================================================================ # ============================================================================
const REMOTE_PLAYER_SCENE: PackedScene = preload("res://scenes/characters/remote_player.tscn") 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 CHAT_BUBBLE_SCENE: PackedScene = preload("res://scenes/ui/ChatBubble.tscn")
const DEFAULT_MAP_ID: String = "whale_port" const DEFAULT_MAP_ID: String = "whale_port"
const POSITION_SEND_INTERVAL: float = 0.12 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 map_id: String = DEFAULT_MAP_ID
@export var local_player_path: NodePath = NodePath("../Characters/Players/Player") @export var local_player_path: NodePath = NodePath("../Characters/Players/Player")
@export var remote_players_root_path: NodePath = NodePath("../Characters/Players/RemotePlayers") @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 _local_player: Node2D
var _remote_players_root: Node2D var _remote_players_root: Node2D
var _network_npcs_root: Node2D
var _remote_players: Dictionary = {} var _remote_players: Dictionary = {}
var _network_npcs: Dictionary = {}
var _pending_remote_players: Dictionary = {} var _pending_remote_players: Dictionary = {}
var _last_sent_position: Vector2 = Vector2.INF var _last_sent_position: Vector2 = Vector2.INF
var _last_sent_at_msec: int = 0 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_private_chat_interaction_msec: int = 0
var _last_friend_request_interaction_msec: int = 0 var _last_friend_request_interaction_msec: int = 0
func _ready() -> void: func _ready() -> void:
_local_player = get_node_or_null(local_player_path) as Node2D _local_player = get_node_or_null(local_player_path) as Node2D
_remote_players_root = get_node_or_null(remote_players_root_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: if _local_player == null:
push_warning("MapMultiplayerController: local player not found.") 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.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.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.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_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_JOINED, _on_remote_player_joined, self)
eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_LEFT, _on_remote_player_left, 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.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.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.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_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_JOINED, _on_remote_player_joined, self)
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_LEFT, _on_remote_player_left, 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: func _on_local_player_moved(data: Dictionary) -> void:
var position_variant: Variant = data.get("position", null) var position_variant: Variant = data.get("position", null)
if position_variant is Vector2: 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: func _send_world_ready() -> void:
if _local_player == null: if _local_player == null:
@@ -183,13 +199,16 @@ func _send_world_ready() -> void:
var chatManager := _get_chat_manager() var chatManager := _get_chat_manager()
if chatManager == null or not chatManager.has_method("mark_world_ready"): if chatManager == null or not chatManager.has_method("mark_world_ready"):
return 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() 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 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 return
var chatManager := _get_chat_manager() var chatManager := _get_chat_manager()
@@ -200,7 +219,10 @@ func _send_position(position: Vector2, force: bool = false) -> void:
_last_sent_position = position _last_sent_position = position
_last_sent_at_msec = now _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: func _on_remote_player_joined(data: Dictionary) -> void:
if not _is_event_for_current_map(data): if not _is_event_for_current_map(data):
@@ -210,8 +232,8 @@ func _on_remote_player_joined(data: Dictionary) -> void:
return return
var remote_player := _ensure_remote_player(user_id, data) var remote_player := _ensure_remote_player(user_id, data)
var position_variant: Variant = data.get("position", null) var position_variant: Variant = data.get("position", null)
if remote_player != null and position_variant is Vector2 and remote_player.has_method("update_position"): if remote_player != null and position_variant is Vector2:
remote_player.call("update_position", position_variant as Vector2) _update_remote_player_position(remote_player, position_variant as Vector2, data)
func _on_remote_players_snapshot_ready(data: Dictionary) -> void: func _on_remote_players_snapshot_ready(data: Dictionary) -> void:
if not _is_event_for_current_map(data): 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 seen_user_ids[user_id] = true
var remote_player := _ensure_remote_player(user_id, player_data) var remote_player := _ensure_remote_player(user_id, player_data)
var position_variant: Variant = player_data.get("position", null) 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"): if remote_player != null and position_variant is Vector2:
remote_player.call("update_position", position_variant as Vector2) _update_remote_player_position(remote_player, position_variant as Vector2, player_data)
for user_id_variant in _remote_players.keys().duplicate(): for user_id_variant in _remote_players.keys().duplicate():
var user_id := str(user_id_variant) 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): if not seen_user_ids.has(pendingUserId):
_pending_remote_players.erase(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: func _on_remote_player_position_updated(data: Dictionary) -> void:
if not _is_event_for_current_map(data): if not _is_event_for_current_map(data):
return return
@@ -260,8 +346,8 @@ func _on_remote_player_position_updated(data: Dictionary) -> void:
return return
var remote_player := _ensure_remote_player(user_id, data) var remote_player := _ensure_remote_player(user_id, data)
if remote_player != null and remote_player.has_method("update_position"): if remote_player != null:
remote_player.call("update_position", position_variant as Vector2) _update_remote_player_position(remote_player, position_variant as Vector2, data)
func _on_remote_player_left(data: Dictionary) -> void: func _on_remote_player_left(data: Dictionary) -> void:
if not _is_event_for_current_map(data): 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) _pending_remote_players.erase(userId)
var remotePlayer := _ensure_remote_player(userId, pendingData) var remotePlayer := _ensure_remote_player(userId, pendingData)
var positionVariant: Variant = pendingData.get("position", null) var positionVariant: Variant = pendingData.get("position", null)
if remotePlayer != null and positionVariant is Vector2 and remotePlayer.has_method("update_position"): if remotePlayer != null and positionVariant is Vector2:
remotePlayer.call("update_position", positionVariant as Vector2) _update_remote_player_position(remotePlayer, positionVariant as Vector2, pendingData)
func _on_remote_skin_failed(data: Dictionary) -> void: func _on_remote_skin_failed(data: Dictionary) -> void:
var skinId := str(data.get("skin_id", "")).strip_edges() var skinId := str(data.get("skin_id", "")).strip_edges()
@@ -372,14 +458,14 @@ func _on_remote_skin_failed(data: Dictionary) -> void:
continue continue
_pending_remote_players.erase(userId) _pending_remote_players.erase(userId)
var fallbackData := pendingData.duplicate(true) var fallbackData := pendingData.duplicate(true)
fallbackData["skin_id"] = "" fallbackData["skin_id"] = "classic_whale"
fallbackData["skinId"] = "" fallbackData["skinId"] = "classic_whale"
fallbackData.erase("skin_asset") fallbackData.erase("skin_asset")
fallbackData.erase("skinAsset") fallbackData.erase("skinAsset")
var remotePlayer := _ensure_remote_player(userId, fallbackData) var remotePlayer := _ensure_remote_player(userId, fallbackData)
var positionVariant: Variant = pendingData.get("position", null) var positionVariant: Variant = pendingData.get("position", null)
if remotePlayer != null and positionVariant is Vector2 and remotePlayer.has_method("update_position"): if remotePlayer != null and positionVariant is Vector2:
remotePlayer.call("update_position", positionVariant as Vector2) _update_remote_player_position(remotePlayer, positionVariant as Vector2, pendingData)
func _find_private_chat_target() -> Node2D: func _find_private_chat_target() -> Node2D:
if _local_player == null or _remote_players.is_empty(): 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() var username := str(data.get("username", "")).strip_edges()
if not username.is_empty(): if not username.is_empty():
remote_player.set("username", username) 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("update_metadata"):
if remote_player.has_method("setup"): remote_player.call("update_metadata", data)
remote_player.call("setup", 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: func _resolve_chat_bubble_target(data: Dictionary) -> Node2D:
var from_user := str(data.get("from_user", data.get("username", ""))).strip_edges() var from_user := str(data.get("from_user", data.get("username", ""))).strip_edges()

View File

@@ -20,6 +20,9 @@ const CAMERA_LIMIT_BOTTOM: int = 960
func _ready() -> void: func _ready() -> void:
_apply_spawn_point() _apply_spawn_point()
_configure_camera() _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: func _apply_spawn_point() -> void:
var spawnName: String = SceneManager.get_next_spawn_name() var spawnName: String = SceneManager.get_next_spawn_name()

View File

@@ -146,6 +146,9 @@ disabled = true
[node name="RemotePlayers" type="Node2D" parent="YSortWorld/Characters/Players" unique_id=2071839484] [node name="RemotePlayers" type="Node2D" parent="YSortWorld/Characters/Players" unique_id=2071839484]
y_sort_enabled = true 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")] [node name="CafeWhaleBaristaNpc" parent="YSortWorld/Characters" unique_id=1188569491 instance=ExtResource("8_barista_npc")]
position = Vector2(-472, -291) position = Vector2(-472, -291)

View File

@@ -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://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="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/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="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://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://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"] [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="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/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/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="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://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"] [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="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="SettingsPanel" parent="UILayer" unique_id=588068795 instance=ExtResource("41_settingspanel")]
[node name="HDGrassBase" type="Sprite2D" parent="." unique_id=1853750890] [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] [node name="Npcs" type="Node2D" parent="YSortWorld/Characters" unique_id=1114010880]
y_sort_enabled = true 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] [node name="MultiplayerController" type="Node" parent="YSortWorld" unique_id=1810372571]
script = ExtResource("38_multiplayer") script = ExtResource("38_multiplayer")

View File

@@ -241,6 +241,9 @@ position = Vector2(0, 921)
y_sort_enabled = true y_sort_enabled = true
position = Vector2(0, -1) 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] [node name="MultiplayerController" type="Node" parent="YSortWorld" unique_id=1024843959]
script = ExtResource("26_multiplayer") script = ExtResource("26_multiplayer")
map_id = "work_zone" map_id = "work_zone"

View File

@@ -8,27 +8,27 @@ class_name NPCController
# 主要功能: # 主要功能:
# - 播放 NPC 待机动画 # - 播放 NPC 待机动画
# - 响应玩家射线交互 # - 响应玩家射线交互
# - 触发聊天气泡与 NPC 对话事件 # - 打开 NPC 对话框并广播 NPC 对话事件
# #
# 依赖: EventSystem, EventNames, ChatBubble # 依赖: EventSystem, EventNames, ChatUI
# 作者: Codex # 作者: Codex
# 创建时间: 2026-03-10 # 创建时间: 2026-03-10
# ============================================================================ # ============================================================================
signal interaction_happened(text: String) 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_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_SORT_Z_OFFSET: int = 2048
const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres") const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres")
const NAMEPLATE_RENDER_SCALE: float = 0.5 const NAMEPLATE_RENDER_SCALE: float = 0.5
const NAMEPLATE_FONT_SIZE: int = 24 const NAMEPLATE_FONT_SIZE: int = 16
const NAMEPLATE_VISUAL_HEIGHT: int = 22 const NAMEPLATE_VISUAL_HEIGHT: int = 15
const NAMEPLATE_VISUAL_MIN_WIDTH: int = 76 const NAMEPLATE_VISUAL_MIN_WIDTH: int = 64
const NAMEPLATE_VISUAL_MAX_WIDTH: int = 118 const NAMEPLATE_VISUAL_MAX_WIDTH: int = 100
const NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12 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 var npcName: String = "NPC"
@export_multiline var dialogue: String = "欢迎来到WhaleTown我是镇长范鲸晶" @export_multiline var dialogue: String = "欢迎来到WhaleTown我是镇长范鲸晶"
@@ -38,6 +38,7 @@ const NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12
@onready var animation_player: AnimationPlayer = $AnimationPlayer @onready var animation_player: AnimationPlayer = $AnimationPlayer
var _nameplate: Label var _nameplate: Label
var _nicknameFont: FontFile
func _ready() -> void: func _ready() -> void:
# 播放场景里配置好的待机动画,让不同 NPC 可以复用同一个控制器。 # 播放场景里配置好的待机动画,让不同 NPC 可以复用同一个控制器。
@@ -46,16 +47,18 @@ func _ready() -> void:
_update_nameplate() _update_nameplate()
_update_world_sort_z() _update_world_sort_z()
# 保持 NPC 可被玩家射线与角色碰撞识别 # NPC 单独占用交互层;玩家的物理掩码同时包含地图层和 NPC 层
collision_layer = 3 collision_layer = NPC_COLLISION_LAYER
collision_mask = 3 collision_mask = NPC_COLLISION_MASK
func _physics_process(_delta: float) -> void: func _physics_process(_delta: float) -> void:
_update_world_sort_z() _update_world_sort_z()
# 处理玩家交互,展示气泡并向全局事件系统广播。 # 处理玩家交互,打开统一对话框并向全局事件系统广播。
func interact() -> void: 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") var eventSystem: Node = get_node_or_null("/root/EventSystem")
if eventSystem != null: if eventSystem != null:
eventSystem.call("emit_event", NPC_TALKED_EVENT, { eventSystem.call("emit_event", NPC_TALKED_EVENT, {
@@ -65,31 +68,6 @@ func interact() -> void:
}) })
interaction_happened.emit(dialogue) 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: func _update_world_sort_z() -> void:
z_index = WORLD_SORT_Z_OFFSET + int(round(global_position.y)) z_index = WORLD_SORT_Z_OFFSET + int(round(global_position.y))
@@ -123,34 +101,20 @@ func _update_nameplate() -> void:
_nameplate.clip_text = true _nameplate.clip_text = true
_nameplate.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS _nameplate.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
_nameplate.mouse_filter = Control.MOUSE_FILTER_IGNORE _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_color", Color(0.09, 0.25, 0.31, 1.0))
_nameplate.add_theme_color_override("font_shadow_color", Color(1.0, 1.0, 1.0, 0.85)) _nameplate.add_theme_color_override("font_outline_color", Color(1.0, 0.965, 0.88, 0.98))
_nameplate.add_theme_constant_override("shadow_offset_x", 0) _nameplate.add_theme_constant_override("outline_size", 3)
_nameplate.add_theme_constant_override("shadow_offset_y", 1) _nameplate.add_theme_font_override("font", _get_nickname_font())
_nameplate.add_theme_font_size_override("font_size", NAMEPLATE_FONT_SIZE) _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: func _nameplate_visual_width(displayName: String) -> int:
var estimatedWidth := displayName.length() * NAMEPLATE_VISUAL_CHAR_WIDTH + 28 var measuredWidth := _get_nickname_font().get_string_size(displayName, HORIZONTAL_ALIGNMENT_LEFT, -1, NAMEPLATE_FONT_SIZE).x
return clampi(estimatedWidth, NAMEPLATE_VISUAL_MIN_WIDTH, NAMEPLATE_VISUAL_MAX_WIDTH) 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: func _get_nickname_font() -> FontFile:
var style := StyleBoxFlat.new() if _nicknameFont == null:
style.bg_color = Color(1.0, 0.988, 0.955, 0.96) _nicknameFont = NAMEPLATE_FONT_ZH.duplicate() as FontFile
style.border_color = Color(0.18, 0.34, 0.38, 0.92) _nicknameFont.fallbacks = [NAMEPLATE_FONT_LATIN]
style.border_width_left = 2 return _nicknameFont
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

View File

@@ -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

View File

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

View File

@@ -6,10 +6,23 @@ signal player_moved(position: Vector2)
# 常量定义 # 常量定义
const MOVE_SPEED = 200.0 const MOVE_SPEED = 200.0
const PLAYER_COLLISION_LAYER = 1
const PLAYER_COLLISION_MASK = 3
const WORLD_SORT_Z_OFFSET = 2048 const WORLD_SORT_Z_OFFSET = 2048
const INTERACTION_COLLISION_MASK = 2 const INTERACTION_COLLISION_MASK = 2
const WALK_ANIMATION_LENGTH = 0.8 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 = { const DIRECTION_ROWS: Dictionary = {
"down": 0, "down": 0,
"up": 1, "up": 1,
@@ -24,9 +37,32 @@ const DIRECTION_ROWS: Dictionary = {
var lastDirection: String = "down" var lastDirection: String = "down"
var _nameLabel: Label var _nameLabel: Label
var _nicknameFont: FontFile
var _cachedNameLabelText: String = ""
var _cachedNameLabelDirection: String = ""
var _cachedNameLabelVisible: bool = false
var _movementLocked: 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: func _ready() -> void:
collision_layer = PLAYER_COLLISION_LAYER
collision_mask = PLAYER_COLLISION_MASK
_reset_movement_input_state() _reset_movement_input_state()
_apply_current_appearance() _apply_current_appearance()
_subscribe_to_appearance_events() _subscribe_to_appearance_events()
@@ -81,6 +117,8 @@ func _physics_process(delta: float) -> void:
_handle_interaction() _handle_interaction()
func _handle_interaction() -> void: func _handle_interaction() -> void:
if _spectator_mode:
return
if _is_text_input_focused(): if _is_text_input_focused():
return return
if Input.is_action_just_pressed("interact"): if Input.is_action_just_pressed("interact"):
@@ -100,6 +138,7 @@ func _handle_movement(_delta: float) -> void:
velocity = Vector2.ZERO velocity = Vector2.ZERO
_play_idle_animation() _play_idle_animation()
move_and_slide() move_and_slide()
_emit_movement_sync("idle")
return return
# 输入框获得焦点时禁止移动,避免聊天/表单输入影响角色 # 输入框获得焦点时禁止移动,避免聊天/表单输入影响角色
@@ -108,6 +147,7 @@ func _handle_movement(_delta: float) -> void:
velocity = Vector2.ZERO velocity = Vector2.ZERO
_play_idle_animation() _play_idle_animation()
move_and_slide() move_and_slide()
_emit_movement_sync("idle")
return return
# 获取移动向量 (参考 docs/02-开发规范/输入映射配置.md) # 获取移动向量 (参考 docs/02-开发规范/输入映射配置.md)
@@ -126,17 +166,28 @@ func _handle_movement(_delta: float) -> void:
move_and_slide() move_and_slide()
# 发送移动事件 (如果位置发生明显变化) # 移动中持续发送位置;停止时额外发送一次 idle供远端切回待机动画。
if velocity.length() > 0: 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) player_moved.emit(global_position)
if movementState == "walk" or movementState != _lastEmittedMovementState:
EventSystem.emit_event(EventNames.PLAYER_MOVED, { 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: func _update_animation_state(direction: Vector2) -> void:
if not animation_player: if not animation_player:
return return
var previousDirection := lastDirection
# Determine primary direction # Determine primary direction
if abs(direction.x) > abs(direction.y): if abs(direction.x) > abs(direction.y):
if direction.x > 0: if direction.x > 0:
@@ -153,6 +204,8 @@ func _update_animation_state(direction: Vector2) -> void:
lastDirection = "up" lastDirection = "up"
ray_cast.target_position = Vector2(0, -60) ray_cast.target_position = Vector2(0, -60)
if lastDirection != previousDirection:
_update_name_label()
animation_player.play("walk_" + lastDirection) animation_player.play("walk_" + lastDirection)
func _play_idle_animation() -> void: func _play_idle_animation() -> void:
@@ -241,19 +294,61 @@ func _create_name_label() -> void:
return return
_nameLabel = Label.new() _nameLabel = Label.new()
_nameLabel.name = "NameLabel" _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) add_child(_nameLabel)
func _update_name_label() -> void: func _update_name_label() -> void:
if not is_instance_valid(_nameLabel): if not is_instance_valid(_nameLabel):
return return
_nameLabel.text = _current_username() var displayName := _current_username()
_nameLabel.visible = _settings_bool("show_name_always", false) 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: func _current_username() -> String:
var authManager := get_node_or_null("/root/AuthManager") 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"): if settingsManager != null and settingsManager.has_method("get_bool"):
return bool(settingsManager.call("get_bool", key)) return bool(settingsManager.call("get_bool", key))
return defaultValue 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

View File

@@ -7,17 +7,35 @@ class_name RemotePlayer
var userId: String = "" var userId: String = ""
var username: 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 skinAsset: Dictionary = {}
var avatarId: String = "" var avatarId: String = ""
var targetPosition: Vector2 = Vector2.ZERO var targetPosition: Vector2 = Vector2.ZERO
var cafeCompanionData: Dictionary = {} 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" var lastDirection: String = "down"
const WORLD_SORT_Z_OFFSET = 2048 const WORLD_SORT_Z_OFFSET = 2048
const WALK_ANIMATION_LENGTH = 0.8 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_RENDER_SCALE: float = 0.5
const CAFE_NAME_LABEL_FONT_SIZE: int = 24 const CAFE_NAME_LABEL_FONT_SIZE: int = 24
const CAFE_NAME_LABEL_VISUAL_HEIGHT: int = 22 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_VISUAL_CHAR_WIDTH: int = 12
const CAFE_NAME_LABEL_OFFSET_Y: float = -96.0 const CAFE_NAME_LABEL_OFFSET_Y: float = -96.0
const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres") 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 = { const DIRECTION_ROWS: Dictionary = {
"down": 0, "down": 0,
"up": 1, "up": 1,
@@ -73,37 +93,15 @@ func _process(delta: float) -> void:
global_position = newPos global_position = newPos
_update_world_sort_z() _update_world_sort_z()
else: else:
# 距离很近时直接吸附并播放待机动画 # 距离很近时吸附;动画状态由发送端的 idle/walk 决定。
global_position = targetPosition global_position = targetPosition
_update_world_sort_z() _update_world_sort_z()
_play_idle_animation() _play_current_animation()
# 统一初始化方法 # 统一初始化方法
# data: 包含 camelCase 字段的字典 (userId, username, position 等) # data: 包含 camelCase 字段的字典 (userId, username, position 等)
func setup(data: Dictionary) -> void: func setup(data: Dictionary) -> void:
if data.has("userId"): update_metadata(data)
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()
if data.has("position"): if data.has("position"):
var positionData: Variant = data.position var positionData: Variant = data.position
@@ -111,15 +109,73 @@ func setup(data: Dictionary) -> void:
global_position = positionData global_position = positionData
targetPosition = positionData targetPosition = positionData
_update_world_sort_z() _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) var newPos := Vector2(positionData.x, positionData.y)
global_position = newPos global_position = newPos
targetPosition = newPos targetPosition = newPos
_update_world_sort_z() _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 targetPosition = newPos
if global_position.distance_to(targetPosition) <= 1.0:
_play_current_animation()
func _update_world_sort_z() -> void: func _update_world_sort_z() -> void:
z_index = WORLD_SORT_Z_OFFSET + int(round(global_position.y)) 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: if not animation_player:
return return
# 确定主方向 # 新协议使用发送端方向;兼容旧位置包时再从位移向量推断。
if abs(moveVec.x) > abs(moveVec.y): if not _hasSyncedDirection:
if moveVec.x > 0: if abs(moveVec.x) > abs(moveVec.y):
lastDirection = "right" if moveVec.x > 0:
lastDirection = "right"
else:
lastDirection = "left"
else: else:
lastDirection = "left" if moveVec.y > 0:
else: lastDirection = "down"
if moveVec.y > 0: else:
lastDirection = "down" lastDirection = "up"
else:
lastDirection = "up"
animation_player.play("walk_" + lastDirection) 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: func _play_idle_animation() -> void:
if animation_player: if animation_player:
animation_player.play("idle_" + lastDirection) animation_player.play("idle_" + lastDirection)
@@ -206,25 +285,26 @@ func _create_name_label() -> void:
return return
_nameLabel = Label.new() _nameLabel = Label.new()
_nameLabel.name = "NameLabel" _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) add_child(_nameLabel)
func _update_name_label() -> void: func _update_name_label() -> void:
if not is_instance_valid(_nameLabel): if not is_instance_valid(_nameLabel):
return return
var personaName := str(cafeCompanionData.get("persona_name", "")).strip_edges() 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(): if not personaName.is_empty():
_configure_cafe_name_label(personaName) _configure_cafe_name_label(personaName)
_nameLabel.visible = true else:
return _configure_default_name_label()
_configure_default_name_label() _nameLabel.text = displayName
_nameLabel.text = username if not username.strip_edges().is_empty() else "玩家" _nameLabel.visible = showName
_nameLabel.visible = _settings_bool("show_name_always", false) _cachedNameLabelText = displayName
_cachedNameLabelPersona = personaName
_cachedNameLabelDirection = lastDirection
_cachedNameLabelVisible = showName
func _configure_cafe_name_label(personaName: String) -> void: func _configure_cafe_name_label(personaName: String) -> void:
var displayName := personaName.strip_edges() var displayName := personaName.strip_edges()
@@ -246,30 +326,61 @@ func _configure_cafe_name_label(personaName: String) -> void:
_nameLabel.clip_text = true _nameLabel.clip_text = true
_nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS _nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
_nameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE _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_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_x", 0)
_nameLabel.add_theme_constant_override("shadow_offset_y", 1) _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_font_size_override("font_size", CAFE_NAME_LABEL_FONT_SIZE)
_nameLabel.add_theme_stylebox_override("normal", _create_cafe_name_label_style()) _nameLabel.add_theme_stylebox_override("normal", _create_cafe_name_label_style())
func _configure_default_name_label() -> void: func _configure_default_name_label() -> void:
_nameLabel.theme = null var displayName := username if not username.strip_edges().is_empty() else "玩家"
_nameLabel.position = NAME_LABEL_OFFSET var visualWidth := _name_label_visual_width(displayName)
_nameLabel.scale = Vector2.ONE var renderWidth := ceili(float(visualWidth) / NAME_LABEL_RENDER_SCALE)
_nameLabel.custom_minimum_size = Vector2(140, 28) 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.size = _nameLabel.custom_minimum_size
_nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_nameLabel.vertical_alignment = VERTICAL_ALIGNMENT_TOP _nameLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_nameLabel.clip_text = false _nameLabel.clip_text = true
_nameLabel.text_overrun_behavior = TextServer.OVERRUN_NO_TRIMMING _nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
_nameLabel.mouse_filter = Control.MOUSE_FILTER_STOP _nameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_nameLabel.add_theme_color_override("font_color", Color(0.188, 0.294, 0.424)) _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_color_override("font_shadow_color")
_nameLabel.remove_theme_constant_override("shadow_offset_x") _nameLabel.remove_theme_constant_override("shadow_offset_x")
_nameLabel.remove_theme_constant_override("shadow_offset_y") _nameLabel.remove_theme_constant_override("shadow_offset_y")
_nameLabel.add_theme_font_size_override("font_size", 14) _nameLabel.add_theme_color_override("font_outline_color", Color(1.0, 0.965, 0.88, 0.98))
_nameLabel.add_theme_stylebox_override("normal", _create_name_label_style()) _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: func _cafe_name_label_visual_width(displayName: String) -> int:
var estimatedWidth := displayName.length() * CAFE_NAME_LABEL_VISUAL_CHAR_WIDTH + 28 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 bool(settingsManager.call("get_bool", key))
return defaultValue return defaultValue
func _create_name_label_style() -> StyleBoxFlat: func _is_guest_mode() -> bool:
var style := StyleBoxFlat.new() var chatManager := get_node_or_null("/root/ChatManager")
style.bg_color = Color(1.0, 1.0, 1.0, 0.74) return chatManager != null and chatManager.has_method("is_guest_mode") and bool(chatManager.call("is_guest_mode"))
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 _create_cafe_name_label_style() -> StyleBoxFlat: func _create_cafe_name_label_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new() var style := StyleBoxFlat.new()

View File

@@ -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

View File

@@ -157,6 +157,8 @@ _data = {
[node name="Player" type="CharacterBody2D"] [node name="Player" type="CharacterBody2D"]
script = ExtResource("1_script") script = ExtResource("1_script")
collision_layer = 1
collision_mask = 3
[node name="Sprite2D" type="Sprite2D" parent="."] [node name="Sprite2D" type="Sprite2D" parent="."]
texture_filter = 2 texture_filter = 2

View File

@@ -163,6 +163,41 @@ func set_message(from_user: String, content: String, timestamp: float, is_self:
# 应用样式 # 应用样式
_apply_style() _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)
# ============================================================================ # ============================================================================
# 内部方法 - 样式处理 # 内部方法 - 样式处理
# ============================================================================ # ============================================================================

View File

@@ -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_REFERENCE_UPLOAD_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/reference_upload_box.png"
const REGISTRATION_CHOICE_IMAGE_PLACEHOLDER_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/image_placeholder_box.png" const REGISTRATION_CHOICE_IMAGE_PLACEHOLDER_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/image_placeholder_box.png"
const UI_FONT_PATH: String = "res://assets/fonts/msyh-web.ttf" 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 NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const WebFilePicker = preload("res://_Core/utils/WebFilePicker.gd") const WebFilePicker = preload("res://_Core/utils/WebFilePicker.gd")
const SKIN_GENERATION_CREATE_ENDPOINT: String = "/api/skin-generation/jobs" const SKIN_GENERATION_CREATE_ENDPOINT: String = "/api/skin-generation/jobs"
const SKIN_GENERATION_POLL_ENDPOINT_TEMPLATE: String = "/api/skin-generation/jobs/%s" const SKIN_GENERATION_POLL_ENDPOINT_TEMPLATE: String = "/api/skin-generation/jobs/%s"
const SKIN_GENERATION_POLL_INTERVAL: float = 2.0 const SKIN_GENERATION_POLL_INTERVAL: float = 2.0
const SKIN_GENERATION_REQUEST_TIMEOUT: float = 24.0
const AVATAR_NATIVE_FILE_FILTERS: Array[String] = ["*.png,*.jpg,*.jpeg,*.webp"] const AVATAR_NATIVE_FILE_FILTERS: Array[String] = ["*.png,*.jpg,*.jpeg,*.webp"]
const SKIN_NATIVE_FILE_FILTERS: Array[String] = ["*.png"] const SKIN_NATIVE_FILE_FILTERS: Array[String] = ["*.png"]
const WEB_FILE_MAX_BYTES: int = 8 * 1024 * 1024 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 LOGIN_FRAME_RECT: Rect2 = Rect2(270, 40, 780, 680)
const REGISTER_FRAME_RECT: Rect2 = Rect2(150, 15, 1020, 727) const REGISTER_FRAME_RECT: Rect2 = Rect2(150, 15, 1020, 727)
const LOGIN_FORM_RECT: Rect2 = Rect2(432, 210, 430, 430) 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 whale_frame: TextureRect = %WhaleFrame
@onready var login_panel: PanelContainer = %LoginPanel @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_identifier_input: LineEdit = %LoginIdentifierInput
@onready var login_password_input: LineEdit = %LoginPasswordInput @onready var login_password_input: LineEdit = %LoginPasswordInput
@onready var login_button: Button = %LoginButton @onready var login_button: Button = %LoginButton
@onready var guest_button: Button = %GuestButton
@onready var show_register_button: Button = %ShowRegisterButton @onready var show_register_button: Button = %ShowRegisterButton
@onready var register_username_input: LineEdit = %RegisterUsernameInput @onready var register_username_input: LineEdit = %RegisterUsernameInput
@onready var register_invitation_code_input: LineEdit = %RegisterInvitationCodeInput
@onready var register_email_input: LineEdit = %RegisterEmailInput @onready var register_email_input: LineEdit = %RegisterEmailInput
@onready var send_register_code_button: Button = %SendRegisterCodeButton @onready var send_register_code_button: Button = %SendRegisterCodeButton
@onready var register_verification_code_input: LineEdit = %RegisterVerificationCodeInput @onready var register_verification_code_input: LineEdit = %RegisterVerificationCodeInput
@@ -96,8 +98,6 @@ var _brand_font: SystemFont
var _choice_font: Font var _choice_font: Font
var _resuming_cached_session: bool = false var _resuming_cached_session: bool = false
var _cached_resume_start_generation: int = 0 var _cached_resume_start_generation: int = 0
var _skin_generation_create_request: HTTPRequest
var _skin_generation_poll_request: HTTPRequest
var _skin_generation_active: bool = false var _skin_generation_active: bool = false
var _skin_generation_job_id: String = "" var _skin_generation_job_id: String = ""
var _skin_generation_poll_elapsed: float = 0.0 var _skin_generation_poll_elapsed: float = 0.0
@@ -110,6 +110,7 @@ var _registration_official_skins: Array = []
var _registration_official_skin_index: int = 0 var _registration_official_skin_index: int = 0
var _registration_avatar_source_path: String = "" var _registration_avatar_source_path: String = ""
var _registration_skin_source_path: String = "" var _registration_skin_source_path: String = ""
var _registration_avatar_controls_visible: bool = false
var _workshop_source_image_path: String = "" var _workshop_source_image_path: String = ""
var _awaiting_registration_skin_generation: bool = false var _awaiting_registration_skin_generation: bool = false
var _awaiting_registration_skin_choice: bool = false var _awaiting_registration_skin_choice: bool = false
@@ -210,7 +211,7 @@ func _build_login_form(parent: Control) -> void:
box.offset_right = -12 box.offset_right = -12
parent.add_child(box) parent.add_child(box)
var title := _brand_label("TitleLabel", "WhaleTown V2") var title := _brand_label("TitleLabel", "WhaleTown")
title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
box.add_child(title) box.add_child(title)
box.add_child(HSeparator.new()) 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)) var loginButton := _primary_button("LoginButton", "进入小镇", 25, Vector2(396, 58))
loginButton.unique_name_in_owner = true loginButton.unique_name_in_owner = true
box.add_child(loginButton) 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() var links := HBoxContainer.new()
links.name = "BottomLinks" links.name = "BottomLinks"
@@ -268,7 +272,7 @@ func _build_register_form(parent: Control) -> void:
var box := VBoxContainer.new() var box := VBoxContainer.new()
box.name = "RegisterVBox" box.name = "RegisterVBox"
box.add_theme_constant_override("separation", 3) 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) content.add_child(box)
var title := _label("RegisterTitleLabel", "注册新居民", 22, TEXT_COLOR, HORIZONTAL_ALIGNMENT_CENTER) 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 usernameInput.unique_name_in_owner = true
box.add_child(usernameInput) 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("邮箱")) box.add_child(_register_form_label("邮箱"))
var emailRow := HBoxContainer.new() var emailRow := HBoxContainer.new()
emailRow.name = "RegisterEmailRow" emailRow.name = "RegisterEmailRow"
@@ -312,7 +322,7 @@ func _build_register_form(parent: Control) -> void:
var buttonSpacer := Control.new() var buttonSpacer := Control.new()
buttonSpacer.name = "RegisterButtonSpacer" buttonSpacer.name = "RegisterButtonSpacer"
buttonSpacer.custom_minimum_size = Vector2(0, 12) buttonSpacer.custom_minimum_size = Vector2(0, 4)
box.add_child(buttonSpacer) box.add_child(buttonSpacer)
var buttons := HBoxContainer.new() var buttons := HBoxContainer.new()
@@ -692,11 +702,12 @@ func _ready() -> void:
_scene_manager = get_node_or_null("/root/SceneManager") _scene_manager = get_node_or_null("/root/SceneManager")
_appearance_manager = get_node_or_null("/root/AppearanceManager") _appearance_manager = get_node_or_null("/root/AppearanceManager")
_setup_skin_generation_requests()
_connect_signals() _connect_signals()
_refresh_appearance_ui() _refresh_appearance_ui()
_show_login() _show_login()
_notify_web_shell_ready() _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(): if _auth_manager != null and bool(_auth_manager.call("is_authenticated")) and _should_auto_resume_cached_session():
_resume_cached_session() _resume_cached_session()
@@ -711,6 +722,7 @@ func _ready() -> void:
func _connect_signals() -> void: func _connect_signals() -> void:
login_button.pressed.connect(_on_login_pressed) login_button.pressed.connect(_on_login_pressed)
guest_button.pressed.connect(_on_guest_pressed)
show_register_button.pressed.connect(_show_register) show_register_button.pressed.connect(_show_register)
send_register_code_button.pressed.connect(_on_send_register_code_pressed) send_register_code_button.pressed.connect(_on_send_register_code_pressed)
register_button.pressed.connect(_on_register_pressed) register_button.pressed.connect(_on_register_pressed)
@@ -757,19 +769,6 @@ func _complete_browser_bootstrap(kind: String) -> void:
return return
_on_login_succeeded(_auth_manager.call("get_current_user")) _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: func _process(delta: float) -> void:
if not _skin_generation_active or _skin_generation_job_id.is_empty(): if not _skin_generation_active or _skin_generation_job_id.is_empty():
return return
@@ -779,10 +778,6 @@ func _process(delta: float) -> void:
_poll_skin_generation_job() _poll_skin_generation_job()
func _exit_tree() -> void: func _exit_tree() -> void:
if is_instance_valid(_skin_generation_create_request):
_skin_generation_create_request.cancel_request()
if is_instance_valid(_skin_generation_poll_request):
_skin_generation_poll_request.cancel_request()
_skin_generation_active = false _skin_generation_active = false
_skin_generation_job_id = "" _skin_generation_job_id = ""
@@ -936,6 +931,7 @@ func _set_registration_character_controls_visible(visible: bool) -> void:
_refresh_appearance_ui() _refresh_appearance_ui()
func _set_registration_avatar_controls_visible(visible: bool) -> void: func _set_registration_avatar_controls_visible(visible: bool) -> void:
_registration_avatar_controls_visible = visible
if not is_instance_valid(registration_character_area): if not is_instance_valid(registration_character_area):
return return
var controlNames := [ var controlNames := [
@@ -968,6 +964,7 @@ func _refresh_character_preview() -> void:
character_sprite.modulate = Color.WHITE character_sprite.modulate = Color.WHITE
character_sprite.frame = 0 character_sprite.frame = 0
character_sprite.scale = _sprite_scale_for_height(character_sprite.texture, character_sprite.vframes, MAIN_PREVIEW_HEIGHT) 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() _clear_workshop_preview()
return return
if _appearance_manager.has_method("apply_skin_to_sprite"): 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) _appearance_manager.call("apply_skin_to_sprite", character_sprite, previewSkinId)
character_sprite.frame = 0 character_sprite.frame = 0
character_sprite.scale = _sprite_scale_for_height(character_sprite.texture, character_sprite.vframes, MAIN_PREVIEW_HEIGHT) 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() _clear_workshop_preview()
func _clear_workshop_preview() -> void: 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.vframes = 4
workshop_preview_sprite.frame = 0 workshop_preview_sprite.frame = 0
workshop_preview_sprite.scale = _sprite_scale_for_height(texture, 4, 156.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 workshop_preview_sprite.visible = true
func _show_workshop_source_preview(path: String) -> void: 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.frame = 0
preview.position = Vector2(90, 99) preview.position = Vector2(90, 99)
preview.scale = _sprite_scale_for_frame_bounds(preview.texture, preview.hframes, preview.vframes, Vector2(136, SKIN_THUMB_HEIGHT)) 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 preview.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
button.add_child(preview) button.add_child(preview)
@@ -1170,6 +1170,9 @@ func _skin_texture(skin: Dictionary) -> Texture2D:
func _refresh_avatar_preview() -> void: func _refresh_avatar_preview() -> void:
avatar_preview.custom_minimum_size = Vector2(34, 34) avatar_preview.custom_minimum_size = Vector2(34, 34)
if not _registration_avatar_controls_visible:
avatar_preview.hide()
return
if _registration_avatar_texture == null: if _registration_avatar_texture == null:
_clear_registration_avatar_preview() _clear_registration_avatar_preview()
return return
@@ -1202,7 +1205,7 @@ func _show_registration_avatar_preview(texture: Texture2D) -> void:
return return
avatar_preview.show() avatar_preview.show()
avatar_preview.clip_contents = true 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): if is_instance_valid(avatar_label):
avatar_label.hide() avatar_label.hide()
var textureRect := _get_or_create_registration_avatar_texture_rect() 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.mouse_filter = Control.MOUSE_FILTER_IGNORE
textureRect.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR textureRect.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
textureRect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE 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) textureRect.set_anchors_preset(Control.PRESET_FULL_RECT)
avatar_preview.add_child(textureRect) avatar_preview.add_child(textureRect)
return 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: func _load_registration_avatar_texture(path: String) -> Texture2D:
var cropped := _load_registration_avatar_image(path) var cropped := _load_registration_avatar_image(path)
if cropped == null: if cropped == null:
@@ -1432,23 +1443,19 @@ func _on_generate_skin_pressed() -> void:
"source_image_base64": _image_to_png_base64(sourceImage), "source_image_base64": _image_to_png_base64(sourceImage),
"source_mime_type": "image/png", "source_mime_type": "image/png",
} }
var err := _skin_generation_create_request.request( var apiClient := get_node_or_null("/root/ApiClient")
"%s%s" % [NetworkConfig.get_api_base_url(), SKIN_GENERATION_CREATE_ENDPOINT], if apiClient == null or not apiClient.has_method("post_json"):
_auth_json_headers(),
HTTPClient.METHOD_POST,
JSON.stringify(payload)
)
if err != OK:
_skin_generation_active = false _skin_generation_active = false
_set_skin_generation_controls_enabled(true) _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: func _on_skin_generation_create_completed(success: bool, response: Dictionary, errorInfo: Dictionary) -> void:
var parsed := _parse_skin_generation_response(result, responseCode, body) if not success:
if not bool(parsed.get("ok", false)):
_skin_generation_active = false _skin_generation_active = false
_set_skin_generation_controls_enabled(true) _set_skin_generation_controls_enabled(true)
var errorMessage := str(parsed.get("message", "角色生成任务创建失败")) var errorMessage := str(errorInfo.get("message", "角色生成任务创建失败"))
if errorMessage.contains("已经使用过注册角色生成机会") or errorMessage.contains("没有可用的注册角色生成机会"): if errorMessage.contains("已经使用过注册角色生成机会") or errorMessage.contains("没有可用的注册角色生成机会"):
_awaiting_registration_skin_generation = false _awaiting_registration_skin_generation = false
_set_workshop_generation_status("该账号已完成注册角色生成,正在进入小镇...") _set_workshop_generation_status("该账号已完成注册角色生成,正在进入小镇...")
@@ -1457,7 +1464,13 @@ func _on_skin_generation_create_completed(result: int, responseCode: int, _heade
_set_workshop_generation_status(errorMessage) _set_workshop_generation_status(errorMessage)
return return
var data: Dictionary = parsed.get("data", {}) var 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() _skin_generation_job_id = str(data.get("job_id", "")).strip_edges()
if _skin_generation_job_id.is_empty(): if _skin_generation_job_id.is_empty():
_skin_generation_active = false _skin_generation_active = false
@@ -1473,26 +1486,30 @@ func _poll_skin_generation_job() -> void:
_skin_generation_poll_in_flight = true _skin_generation_poll_in_flight = true
var endpoint := SKIN_GENERATION_POLL_ENDPOINT_TEMPLATE % _skin_generation_job_id var endpoint := SKIN_GENERATION_POLL_ENDPOINT_TEMPLATE % _skin_generation_job_id
var err := _skin_generation_poll_request.request( var apiClient := get_node_or_null("/root/ApiClient")
"%s%s" % [NetworkConfig.get_api_base_url(), endpoint], if apiClient == null or not apiClient.has_method("get_json"):
_auth_json_headers(),
HTTPClient.METHOD_GET
)
if err != OK:
_skin_generation_poll_in_flight = false _skin_generation_poll_in_flight = false
_set_workshop_generation_status("查询生成状态失败:%s" % error_string(err)) _set_workshop_generation_status("角色生成服务未加载")
return
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 _skin_generation_poll_in_flight = false
var parsed := _parse_skin_generation_response(result, responseCode, body) if not success:
if not bool(parsed.get("ok", false)): var responseCode := int(errorInfo.get("response_code", 0))
if responseCode == 401 or responseCode == 403 or responseCode == 404: if responseCode == 401 or responseCode == 403 or responseCode == 404:
_skin_generation_active = false _skin_generation_active = false
_set_skin_generation_controls_enabled(true) _set_skin_generation_controls_enabled(true)
_set_workshop_generation_status(str(parsed.get("message", "查询生成状态失败"))) _set_workshop_generation_status(str(errorInfo.get("message", "查询生成状态失败")))
return 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 status := str(data.get("status", "")).strip_edges()
var message := str(data.get("message", "")).strip_edges() var message := str(data.get("message", "")).strip_edges()
if not message.is_empty(): if not message.is_empty():
@@ -1578,74 +1595,6 @@ func _set_skin_generation_controls_enabled(enabled: bool) -> void:
if is_instance_valid(workshop_generate_button): if is_instance_valid(workshop_generate_button):
workshop_generate_button.disabled = not enabled workshop_generate_button.disabled = not enabled
func _json_headers() -> PackedStringArray:
return PackedStringArray([
"Content-Type: application/json",
"Accept: application/json",
])
func _auth_json_headers() -> PackedStringArray:
var headers := _json_headers()
if _auth_manager != null and _auth_manager.has_method("get_access_token"):
var token := str(_auth_manager.call("get_access_token")).strip_edges()
if not token.is_empty():
headers.append("Authorization: Bearer %s" % token)
return headers
func _parse_skin_generation_response(result: int, responseCode: int, body: PackedByteArray) -> Dictionary:
if result != HTTPRequest.RESULT_SUCCESS:
return {
"ok": false,
"message": "网络请求失败:%s" % _http_result_to_string(result),
}
var bodyText := body.get_string_from_utf8()
var json := JSON.new()
var error := json.parse(bodyText)
if error != OK or not (json.data is Dictionary):
return {
"ok": false,
"message": "服务器响应解析失败",
}
var response: Dictionary = json.data as Dictionary
var success := responseCode >= 200 and responseCode < 300 and bool(response.get("success", true))
if not success:
return {
"ok": false,
"message": str(response.get("message", "请求失败")),
"response_code": responseCode,
}
var dataVariant: Variant = response.get("data", response)
if not (dataVariant is Dictionary):
return {
"ok": false,
"message": "服务器响应格式错误",
}
return {
"ok": true,
"data": dataVariant as Dictionary,
}
func _http_result_to_string(result: int) -> String:
match result:
HTTPRequest.RESULT_SUCCESS:
return "SUCCESS"
HTTPRequest.RESULT_TIMEOUT:
return "TIMEOUT"
HTTPRequest.RESULT_CANT_CONNECT:
return "CANT_CONNECT"
HTTPRequest.RESULT_CANT_RESOLVE:
return "CANT_RESOLVE"
HTTPRequest.RESULT_CONNECTION_ERROR:
return "CONNECTION_ERROR"
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
return "TLS_HANDSHAKE_ERROR"
_:
return "UNKNOWN_%d" % result
func _hide_skin_workshop() -> void: func _hide_skin_workshop() -> void:
if _awaiting_registration_skin_generation: if _awaiting_registration_skin_generation:
_awaiting_registration_skin_generation = false _awaiting_registration_skin_generation = false
@@ -1953,7 +1902,7 @@ func _on_send_register_code_pressed() -> void:
_is_sending_register_code = true _is_sending_register_code = true
send_register_code_button.disabled = true send_register_code_button.disabled = true
status_label.text = "正在发送邮箱验证码..." 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: func _on_register_pressed() -> void:
if _is_submitting: if _is_submitting:
@@ -1976,7 +1925,8 @@ func _on_register_pressed() -> void:
"", "",
register_email_input.text, register_email_input.text,
register_verification_code_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: func _on_login_succeeded(_user: Dictionary) -> void:
@@ -1987,6 +1937,17 @@ func _on_login_succeeded(_user: Dictionary) -> void:
_auth_manager.call("fetch_profile") _auth_manager.call("fetch_profile")
_enter_square() _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: func _on_login_failed(message: String) -> void:
_set_submitting(false, message) _set_submitting(false, message)
login_password_input.grab_focus() login_password_input.grab_focus()
@@ -2192,6 +2153,7 @@ func _set_submitting(is_submitting: bool, message: String) -> void:
_is_submitting = is_submitting _is_submitting = is_submitting
status_label.text = message status_label.text = message
login_button.disabled = is_submitting login_button.disabled = is_submitting
guest_button.disabled = is_submitting
register_button.disabled = is_submitting register_button.disabled = is_submitting
send_register_code_button.disabled = is_submitting or _is_sending_register_code send_register_code_button.disabled = is_submitting or _is_sending_register_code
show_register_button.disabled = is_submitting 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 return Vector2.ONE
var scale: float = minf(bounds.x / frameSize.x, bounds.y / frameSize.y) var scale: float = minf(bounds.x / frameSize.x, bounds.y / frameSize.y)
return Vector2(scale, scale) 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

View File

@@ -51,13 +51,13 @@ func _draw_polyline(points: Array[Vector2], color: Color, width: float) -> void:
var packed: PackedVector2Array = [] var packed: PackedVector2Array = []
for point in points: for point in points:
packed.append(_p(point)) 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: 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: 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: func _p(point: Vector2) -> Vector2:
return _iconOffset + point * _iconScale return _iconOffset + point * _iconScale

View File

@@ -131,7 +131,9 @@ func _render_service_points() -> void:
var index := servicePointOption.get_item_count() var index := servicePointOption.get_item_count()
servicePointOption.add_item(_format_service_point_option(point)) servicePointOption.add_item(_format_service_point_option(point))
servicePointOption.set_item_metadata(index, pointId) 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 selectedIndex = index
if servicePointOption.get_item_count() <= 0: if servicePointOption.get_item_count() <= 0:
@@ -139,7 +141,14 @@ func _render_service_points() -> void:
submitButton.disabled = true submitButton.disabled = true
return 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 submitButton.disabled = _isSubmitting
fetchModelsButton.disabled = _isSubmitting fetchModelsButton.disabled = _isSubmitting
_set_status("填写人设和代理配置后可登记", false) _set_status("填写人设和代理配置后可登记", false)
@@ -227,6 +236,10 @@ func _build_payload() -> Dictionary:
if servicePointId.is_empty(): if servicePointId.is_empty():
_set_status("请选择陪伴位", true) _set_status("请选择陪伴位", true)
return {} return {}
if not _is_service_point_available(servicePointId):
_set_status("该陪伴位已被占用,请选择其他空位", true)
_request_service_points()
return {}
if personaName.is_empty(): if personaName.is_empty():
_set_status("请填写人设名称", true) _set_status("请填写人设名称", true)
personaNameInput.grab_focus() personaNameInput.grab_focus()
@@ -293,6 +306,15 @@ func _selected_service_point_id() -> String:
return "" return ""
return str(servicePointOption.get_item_metadata(selectedIndex)).strip_edges() 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: func _setup_protocol_options() -> void:
protocolOption.clear() protocolOption.clear()
protocolOption.add_item("OpenAI", 0) protocolOption.add_item("OpenAI", 0)

View File

@@ -22,7 +22,12 @@ func _process(_delta: float) -> void:
_update_position() _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 _originalText = text
_targetNode = targetNode _targetNode = targetNode
_targetOffset = targetOffset _targetOffset = targetOffset
@@ -31,7 +36,7 @@ func set_text(text: String, targetNode: Node2D = null, targetOffset: Vector2 = T
if _targetNode != null: if _targetNode != null:
_update_position() _update_position()
await get_tree().create_timer(DEFAULT_DURATION).timeout await get_tree().create_timer(maxf(0.1, duration)).timeout
queue_free() queue_free()
func _update_size() -> void: func _update_size() -> void:

View File

@@ -56,6 +56,9 @@ extends Control
@onready var friends_list_surface: Control = %FriendsListSurface @onready var friends_list_surface: Control = %FriendsListSurface
@onready var add_friend_row: HBoxContainer = %AddFriendRow @onready var add_friend_row: HBoxContainer = %AddFriendRow
@onready var add_friend_button: Button = %AddFriendButton @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 TAB_FRIENDS: String = "friends"
const MAX_DISPLAYED_MESSAGES: int = 100 const MAX_DISPLAYED_MESSAGES: int = 100
const MOVEMENT_ACTIONS: Array[String] = ["move_left", "move_right", "move_up", "move_down"] const MOVEMENT_ACTIONS: Array[String] = ["move_left", "move_right", "move_up", "move_down"]
const 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 后设置) # 悄悄话目标(靠近玩家按 E 后设置)
var _whisper_target_user_id: String = "" var _whisper_target_user_id: String = ""
var _whisper_target_username: 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 = "" var _friend_target_user_id: String = ""
@@ -118,6 +127,12 @@ var _friends_status_message: String = ""
var _messages: Array[Dictionary] = [] var _messages: Array[Dictionary] = []
var _send_failure_handled_by_ui: bool = false 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: func _ready() -> void:
_configure_mouse_focus() _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) hide_chat(true)
# 创建隐藏计时器 # 创建隐藏计时器
_create_hide_timer() _create_hide_timer()
_create_npc_thinking_timer()
# 订阅事件Call Down via EventSystem # 订阅事件Call Down via EventSystem
_subscribe_to_events() _subscribe_to_events()
@@ -144,6 +164,134 @@ func _ready() -> void:
_update_tab_visuals() _update_tab_visuals()
_update_bubble_send_button_visibility() _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: 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_SUCCESS, _on_login_success, self)
eventSystem.call("disconnect_event", EventNames.CHAT_LOGIN_FAILED, _on_login_failed, 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.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_FRIEND_SELECTED, _on_friend_selected, self)
eventSystem.call("disconnect_event", EventNames.CHAT_FRIENDS_UPDATED, _on_friends_updated, self) eventSystem.call("disconnect_event", EventNames.CHAT_FRIENDS_UPDATED, _on_friends_updated, self)
eventSystem.call("disconnect_event", EventNames.SETTINGS_CHANGED, _on_settings_changed, self) eventSystem.call("disconnect_event", EventNames.SETTINGS_CHANGED, _on_settings_changed, self)
@@ -162,6 +312,11 @@ func _exit_tree() -> void:
# 清理计时器 # 清理计时器
if _hide_timer: if _hide_timer:
_hide_timer.queue_free() _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): if is_instance_valid(_transition_tween):
_transition_tween.kill() _transition_tween.kill()
@@ -192,6 +347,10 @@ func _input(event: InputEvent) -> void:
var key_event := event as InputEventKey var key_event := event as InputEventKey
if not key_event.pressed or key_event.echo: if not key_event.pressed or key_event.echo:
return return
if key_event.keycode == KEY_ESCAPE and _is_chat_visible:
hide_chat()
get_viewport().set_input_as_handled()
return
# T 键用于唤起聊天(输入框聚焦时不拦截) # T 键用于唤起聊天(输入框聚焦时不拦截)
if key_event.keycode == KEY_T and not chat_input.has_focus(): 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(): if _current_tab == TAB_WHISPER and not _whisper_target_username.is_empty():
chat_input.placeholder_text = "%s 说点什么..." % _whisper_target_username chat_input.placeholder_text = "%s 说点什么..." % _whisper_target_username
return 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(): if _current_tab == TAB_FRIENDS and not _friend_target_username.is_empty():
chat_input.placeholder_text = "%s 说点什么..." % _friend_target_username 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: func hide_chat(immediate: bool = false) -> void:
_end_npc_session()
_clear_npc_dialogue_target()
_is_chat_visible = false _is_chat_visible = false
_is_typing = false _is_typing = false
@@ -374,6 +538,13 @@ func _create_hide_timer() -> void:
_hide_timer.timeout.connect(_on_hide_timeout) _hide_timer.timeout.connect(_on_hide_timeout)
add_child(_hide_timer) 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: func _start_hide_timer() -> void:
if _is_typing: if _is_typing:
@@ -508,6 +679,17 @@ func _send_input_message(show_bubble: bool) -> void:
# 清空输入框 # 清空输入框
chat_input.clear() 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 的提交事件在同一帧内抢走焦点 # 发送后延迟重新聚焦,避免被 LineEdit 的提交事件在同一帧内抢走焦点
call_deferred("_focus_input_after_send") 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.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) 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: if _current_tab == TAB_FRIENDS:
_render_friend_conversation_header() _render_friend_conversation_header()
return return
if not _npc_target_id.is_empty() and _current_tab == TAB_WHISPER:
_hide_npc_thinking()
if not message.strip_edges().is_empty(): if not message.strip_edges().is_empty():
_add_system_message(message) _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: func _on_connection_state_changed(_data: Dictionary) -> void:
# 连接状态变化处理当前不更新UI # 连接状态变化处理当前不更新UI
@@ -699,6 +957,8 @@ func _send_current_tab_message(chatManager: Node, content: String, show_bubble:
TAB_WORLD: TAB_WORLD:
return bool(chatManager.call("send_chat_message", content, "global", show_bubble)) return bool(chatManager.call("send_chat_message", content, "global", show_bubble))
TAB_WHISPER: 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(): if _whisper_target_user_id.is_empty():
_add_system_message("请靠近玩家按 E 发起悄悄话") _add_system_message("请靠近玩家按 E 发起悄悄话")
_send_failure_handled_by_ui = true _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(): if normalized_user_id.is_empty():
return return
_end_npc_session()
_clear_npc_dialogue_target()
_whisper_target_user_id = normalized_user_id _whisper_target_user_id = normalized_user_id
_whisper_target_username = username.strip_edges() _whisper_target_username = username.strip_edges()
if _whisper_target_username.is_empty(): if _whisper_target_username.is_empty():
@@ -744,6 +1006,66 @@ func start_whisper(user_id: String, username: String = "") -> void:
show_chat(true) show_chat(true)
call_deferred("_focus_input_after_send") 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: func add_whisper_target_as_friend() -> bool:
if _whisper_target_user_id.is_empty(): if _whisper_target_user_id.is_empty():
_add_system_message("请先靠近玩家按 F") _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(): if normalized_user_id.is_empty():
return return
_end_npc_session()
_clear_npc_dialogue_target()
_friend_target_user_id = normalized_user_id _friend_target_user_id = normalized_user_id
_friend_target_username = username.strip_edges() _friend_target_username = username.strip_edges()
if _friend_target_username.is_empty(): if _friend_target_username.is_empty():
@@ -934,15 +1258,23 @@ func _add_message_data(message: Dictionary) -> void:
_render_message(message) _render_message(message)
func _render_message(message: Dictionary) -> void: func _render_message(message: Dictionary) -> void:
# 如果聊天框隐藏,自动显示 # 世界频道消息进入公告 HUD不自动抢占地图视野
# 私聊、NPC 回复和用户主动发送的消息仍可唤起聊天面板。
if not _is_chat_visible: 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() show_chat()
# 每条消息用一行容器包起来,方便左右对齐且不挤在一起 # 每条消息用一行容器包起来,方便左右对齐且不挤在一起
var row := HBoxContainer.new() var row := HBoxContainer.new()
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.size_flags_vertical = Control.SIZE_SHRINK_BEGIN 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 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)), float(message.get("timestamp", 0.0)),
bool(message.get("is_self", false)) 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") call_deferred("_scroll_to_bottom")
@@ -993,6 +1327,9 @@ func _message_matches_current_tab(message: Dictionary) -> bool:
return true return true
func _private_message_matches_current_tab(message: Dictionary) -> bool: 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 scope := str(message.get("scope", "local")).strip_edges().to_lower()
var is_private := bool(message.get("is_private", false)) or scope == "private" var is_private := bool(message.get("is_private", false)) or scope == "private"
if not is_private: if not is_private:

View File

@@ -9,8 +9,8 @@ extends Control
const LINE_COLOR: Color = Color(0.592157, 0.72549, 0.835294, 0.82) 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_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 DOT_BORDER_COLOR: Color = Color(1.0, 1.0, 1.0, 0.95)
const LINE_WIDTH: float = 0.85 const LINE_WIDTH: float = 1.15
const DETAIL_WIDTH: float = 0.75 const DETAIL_WIDTH: float = 1.0
const BASE_SIZE: float = 27.0 const BASE_SIZE: float = 27.0
var iconName: String = "map" var iconName: String = "map"
@@ -127,17 +127,17 @@ func _draw_polyline(points: Array[Vector2], width: float = LINE_WIDTH) -> void:
var packed: PackedVector2Array = [] var packed: PackedVector2Array = []
for point in points: for point in points:
packed.append(_p(point)) 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: func _draw_red_dot(center: Vector2) -> void:
draw_circle(_p(center), 2.3 * _iconScale, DOT_BORDER_COLOR) draw_circle(_p(center), 2.3 * _iconScale, DOT_BORDER_COLOR, true, -1.0, false)
draw_circle(_p(center), 1.55 * _iconScale, DOT_COLOR) draw_circle(_p(center), 1.55 * _iconScale, DOT_COLOR, true, -1.0, false)
func _draw_line(from: Vector2, to: Vector2, width: float) -> void: 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: 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: func _p(point: Vector2) -> Vector2:
return _iconOffset + point * _iconScale return _iconOffset + point * _iconScale

View File

@@ -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

View File

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

View File

@@ -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")

79
scripts/build_desktop.sh Executable file
View File

@@ -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"

View File

@@ -41,6 +41,8 @@ work_zone=$(build_pack "work_zone" "Scene Work Zone")
cafe=$(build_pack "cafe_interior" "Scene Cafe") cafe=$(build_pack "cafe_interior" "Scene Cafe")
personal_space=$(build_pack "personal_space" "Scene Personal Space") personal_space=$(build_pack "personal_space" "Scene Personal Space")
auth=$(build_pack "auth" "Scene Auth") 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 \ jq -n \
--argjson square "$square" \ --argjson square "$square" \

46
scripts/prepare_site_release.sh Executable file
View File

@@ -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"

View File

@@ -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()

View File

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

View File

@@ -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()

View File

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

View File

@@ -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

View File

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

View File

@@ -21,7 +21,7 @@
</style> </style>
</head> </head>
<body> <body>
<canvas id="canvas">当前浏览器不支持游戏画布。</canvas> <canvas id="canvas" tabindex="0" aria-label="WhaleTown 游戏画布">当前浏览器不支持游戏画布。</canvas>
<div id="loading-shell" aria-live="polite"> <div id="loading-shell" aria-live="polite">
<div class="loading"> <div class="loading">
<p class="status" id="status">正在加载小镇 0%</p> <p class="status" id="status">正在加载小镇 0%</p>
@@ -36,30 +36,39 @@
const GODOT_THREADS_ENABLED = $GODOT_THREADS_ENABLED; const GODOT_THREADS_ENABLED = $GODOT_THREADS_ENABLED;
const CORE_PACK_URL = '__WHALETOWN_CORE_PACK__'; const CORE_PACK_URL = '__WHALETOWN_CORE_PACK__';
const CORE_PACK_SIZE = __WHALETOWN_CORE_PACK_SIZE__; const CORE_PACK_SIZE = __WHALETOWN_CORE_PACK_SIZE__;
const AUTH_PACK_SIZE = __WHALETOWN_AUTH_PACK_SIZE__;
GODOT_CONFIG.mainPack = CORE_PACK_URL; GODOT_CONFIG.mainPack = CORE_PACK_URL;
GODOT_CONFIG.fileSizes[CORE_PACK_URL] = CORE_PACK_SIZE; GODOT_CONFIG.fileSizes[CORE_PACK_URL] = CORE_PACK_SIZE;
const shell = document.getElementById('loading-shell'); const shell = document.getElementById('loading-shell');
const status = document.getElementById('status'); const status = document.getElementById('status');
const bar = document.getElementById('bar'); const bar = document.getElementById('bar');
const failure = document.getElementById('failure'); 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); 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 () { window.whaletownGodotReady = function () {
bar.style.width = '100%'; bar.style.width = '100%';
shell.classList.add('done'); shell.classList.add('done');
window.setTimeout(function () { shell.style.display = 'none'; }, 220); window.setTimeout(function () { shell.style.display = 'none'; }, 220);
document.getElementById('canvas').focus(); canvas.focus();
}; };
window.whaletownPackProgress = function (downloaded, total) { window.whaletownPackProgress = function (downloaded, total) {
const downloadedMb = downloaded / 1048576; const authTotal = total > 0 ? total : AUTH_PACK_SIZE;
if (total > 0) { updateTownProgress(corePhaseTotal + downloaded, corePhaseTotal + authTotal);
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 missing = Engine.getMissingFeatures({ threads: GODOT_THREADS_ENABLED }); const missing = Engine.getMissingFeatures({ threads: GODOT_THREADS_ENABLED });
@@ -70,13 +79,17 @@
engine.startGame({ engine.startGame({
onProgress(current, total) { onProgress(current, total) {
if (total <= 0) return; if (total <= 0) return;
const percent = Math.min(100, Math.round(current / total * 100)); corePhaseTotal = total;
bar.style.width = percent + '%'; if (current >= total) {
status.textContent = '正在加载小镇 ' + percent + '%'; 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 () { }).then(function () {
status.textContent = '正在加载完整登录界面...'; status.textContent = '正在加载小镇,正在启动登录界面...';
bar.style.width = '0';
}).catch(function (error) { }).catch(function (error) {
failure.style.display = 'block'; failure.style.display = 'block';
failure.textContent = error && error.message ? error.message : '主程序加载失败,请刷新重试'; failure.textContent = error && error.message ? error.message : '主程序加载失败,请刷新重试';