Compare commits
12 Commits
refactor-e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fc6c3c1bd3 | |||
| 175621f66c | |||
| 366f967622 | |||
| 6fb135ba71 | |||
| 38df2541ae | |||
| 32bcaef4fa | |||
| 28c5e5ccfb | |||
| a274f6475f | |||
| 52c5a76a01 | |||
| 7a07f52e2c | |||
| bcdaa737eb | |||
| 593e1e4ea1 |
3
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -8,16 +8,16 @@
|
|||||||
"active_profile": "production",
|
"active_profile": "production",
|
||||||
"profiles": {
|
"profiles": {
|
||||||
"production": {
|
"production": {
|
||||||
"api_base_url": "https://whaletownend.xinghangee.icu",
|
"api_base_url": "https://whaletown.novamailio.com/api",
|
||||||
"chat_ws_url": "wss://whaletownend.xinghangee.icu/game",
|
"chat_ws_url": "wss://whaletown.novamailio.com/game",
|
||||||
"location_ws_url": "wss://whaletownend.xinghangee.icu/game",
|
"location_ws_url": "wss://whaletown.novamailio.com/game",
|
||||||
"game_ws_url": "wss://whaletownend.xinghangee.icu/game"
|
"game_ws_url": "wss://whaletown.novamailio.com/game"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"api_base_url": "https://whaletownend.xinghangee.icu",
|
"api_base_url": "https://whaletown.novamailio.com/api",
|
||||||
"chat_ws_url": "wss://whaletownend.xinghangee.icu/game",
|
"chat_ws_url": "wss://whaletown.novamailio.com/game",
|
||||||
"location_ws_url": "wss://whaletownend.xinghangee.icu/game",
|
"location_ws_url": "wss://whaletown.novamailio.com/game",
|
||||||
"game_ws_url": "wss://whaletownend.xinghangee.icu/game",
|
"game_ws_url": "wss://whaletown.novamailio.com/game",
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
"retry_count": 3
|
"retry_count": 3
|
||||||
},
|
},
|
||||||
|
|||||||
37
DESKTOP_BUILD.md
Normal 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 校验值。
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -376,7 +390,7 @@ func has_custom_skin() -> bool:
|
|||||||
return _customSkinActive and _customSkinTexture != null
|
return _customSkinActive and _customSkinTexture != null
|
||||||
|
|
||||||
func get_custom_avatar_texture() -> Texture2D:
|
func get_custom_avatar_texture() -> Texture2D:
|
||||||
if has_custom_avatar():
|
if _customAvatarActive and _customAvatarTexture != null:
|
||||||
return _customAvatarTexture
|
return _customAvatarTexture
|
||||||
return _accountAvatarTexture
|
return _accountAvatarTexture
|
||||||
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -16,12 +16,20 @@ 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)
|
||||||
|
|
||||||
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
|
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
|
||||||
|
|
||||||
const DEFAULT_AUTH_CONFIG_PATH: String = "user://auth.cfg"
|
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_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 = ""
|
||||||
@@ -32,9 +40,33 @@ var _session_generation: int = 0
|
|||||||
var _account_generation: int = 0
|
var _account_generation: int = 0
|
||||||
var _refresh_in_flight: bool = false
|
var _refresh_in_flight: bool = false
|
||||||
var _auth_config_path: String = DEFAULT_AUTH_CONFIG_PATH
|
var _auth_config_path: String = DEFAULT_AUTH_CONFIG_PATH
|
||||||
|
var _pending_registration_username: String = ""
|
||||||
|
var _pending_registration_password: String = ""
|
||||||
|
var _registration_recovery_in_flight: bool = false
|
||||||
|
var _registration_recovery_error: String = ""
|
||||||
|
var _browser_bootstrap_kind: String = ""
|
||||||
|
var _browser_bootstrap_poll_elapsed: float = 0.0
|
||||||
|
var _session_refresh_check_elapsed: float = 0.0
|
||||||
|
var _access_token_expires_at: float = 0.0
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_load_cached_session()
|
_load_cached_session()
|
||||||
|
_try_import_browser_bootstrap(false)
|
||||||
|
if is_authenticated() and _token_needs_refresh():
|
||||||
|
call_deferred("refresh_session")
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
_session_refresh_check_elapsed += delta
|
||||||
|
if _session_refresh_check_elapsed >= SESSION_REFRESH_CHECK_INTERVAL:
|
||||||
|
_session_refresh_check_elapsed = 0.0
|
||||||
|
if is_authenticated() and _token_needs_refresh():
|
||||||
|
refresh_session()
|
||||||
|
|
||||||
|
if OS.get_name() == "Web" and _browser_bootstrap_kind.is_empty():
|
||||||
|
_browser_bootstrap_poll_elapsed += delta
|
||||||
|
if _browser_bootstrap_poll_elapsed >= BROWSER_BOOTSTRAP_POLL_INTERVAL:
|
||||||
|
_browser_bootstrap_poll_elapsed = 0.0
|
||||||
|
_try_import_browser_bootstrap(true)
|
||||||
|
|
||||||
func _exit_tree() -> void:
|
func _exit_tree() -> void:
|
||||||
for request in _active_requests:
|
for request in _active_requests:
|
||||||
@@ -70,6 +102,14 @@ func get_account_generation() -> int:
|
|||||||
func get_auth_config_path() -> String:
|
func get_auth_config_path() -> String:
|
||||||
return _auth_config_path
|
return _auth_config_path
|
||||||
|
|
||||||
|
func consume_browser_bootstrap_kind() -> String:
|
||||||
|
var kind := _browser_bootstrap_kind
|
||||||
|
_browser_bootstrap_kind = ""
|
||||||
|
return kind
|
||||||
|
|
||||||
|
func get_browser_bootstrap_kind() -> String:
|
||||||
|
return _browser_bootstrap_kind
|
||||||
|
|
||||||
func login(identifier: String, password: String) -> void:
|
func login(identifier: String, password: String) -> void:
|
||||||
var normalized_identifier := identifier.strip_edges()
|
var normalized_identifier := identifier.strip_edges()
|
||||||
if normalized_identifier.is_empty():
|
if normalized_identifier.is_empty():
|
||||||
@@ -85,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
|
||||||
@@ -95,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)
|
||||||
@@ -103,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("请输入用户名")
|
||||||
@@ -115,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
|
||||||
@@ -138,12 +187,17 @@ 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():
|
||||||
payload["skin_id"] = normalized_skin_id
|
payload["skin_id"] = normalized_skin_id
|
||||||
|
|
||||||
|
_pending_registration_username = normalized_username
|
||||||
|
_pending_registration_password = password
|
||||||
|
_registration_recovery_in_flight = false
|
||||||
|
_registration_recovery_error = ""
|
||||||
_advance_account_generation()
|
_advance_account_generation()
|
||||||
_request_json("/auth/register", payload, _on_register_response, HTTPClient.METHOD_POST, false, true)
|
_request_json("/auth/register", payload, _on_register_response, HTTPClient.METHOD_POST, false, true)
|
||||||
|
|
||||||
@@ -166,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", {})
|
||||||
@@ -196,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()
|
||||||
@@ -289,14 +347,21 @@ func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary)
|
|||||||
|
|
||||||
func _on_register_response(success: bool, data: Dictionary, error_info: Dictionary) -> void:
|
func _on_register_response(success: bool, data: Dictionary, error_info: Dictionary) -> void:
|
||||||
if not success:
|
if not success:
|
||||||
register_failed.emit(str(error_info.get("message", "注册失败")))
|
var message := str(error_info.get("message", "注册失败"))
|
||||||
|
if _should_recover_registration(message):
|
||||||
|
_recover_registration_session(message)
|
||||||
|
return
|
||||||
|
_clear_pending_registration()
|
||||||
|
register_failed.emit(message)
|
||||||
return
|
return
|
||||||
|
|
||||||
_apply_auth_payload(data)
|
_apply_auth_payload(data)
|
||||||
if not is_authenticated():
|
if not is_authenticated():
|
||||||
|
_clear_pending_registration()
|
||||||
register_failed.emit("注册响应缺少 access_token")
|
register_failed.emit("注册响应缺少 access_token")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
_clear_pending_registration()
|
||||||
_emit_event(EventNames.AUTH_REGISTER_SUCCESS, {
|
_emit_event(EventNames.AUTH_REGISTER_SUCCESS, {
|
||||||
"user": get_current_user()
|
"user": get_current_user()
|
||||||
})
|
})
|
||||||
@@ -304,13 +369,58 @@ func _on_register_response(success: bool, data: Dictionary, error_info: Dictiona
|
|||||||
auth_state_changed.emit(true, get_current_user())
|
auth_state_changed.emit(true, get_current_user())
|
||||||
_refresh_player_snapshot()
|
_refresh_player_snapshot()
|
||||||
|
|
||||||
|
func _should_recover_registration(message: String) -> bool:
|
||||||
|
if _registration_recovery_in_flight or _pending_registration_username.is_empty() or _pending_registration_password.is_empty():
|
||||||
|
return false
|
||||||
|
return message.begins_with("网络请求失败") \
|
||||||
|
or message.begins_with("网络请求发送失败") \
|
||||||
|
or message.contains("用户名已存在") \
|
||||||
|
or message.contains("用户名已被注册")
|
||||||
|
|
||||||
|
func _recover_registration_session(original_error: String) -> void:
|
||||||
|
_registration_recovery_in_flight = true
|
||||||
|
_registration_recovery_error = original_error
|
||||||
|
_request_json("/auth/login", {
|
||||||
|
"identifier": _pending_registration_username,
|
||||||
|
"password": _pending_registration_password,
|
||||||
|
}, _on_registration_recovery_response, HTTPClient.METHOD_POST, false, true)
|
||||||
|
|
||||||
|
func _on_registration_recovery_response(success: bool, data: Dictionary, error_info: Dictionary) -> void:
|
||||||
|
if not success:
|
||||||
|
var original_error := _registration_recovery_error
|
||||||
|
_clear_pending_registration()
|
||||||
|
register_failed.emit(str(error_info.get("message", original_error if not original_error.is_empty() else "注册状态确认失败")))
|
||||||
|
return
|
||||||
|
|
||||||
|
_apply_auth_payload(data)
|
||||||
|
if not is_authenticated():
|
||||||
|
_clear_pending_registration()
|
||||||
|
register_failed.emit("账号已创建,但登录确认失败")
|
||||||
|
return
|
||||||
|
|
||||||
|
_clear_pending_registration()
|
||||||
|
_emit_event(EventNames.AUTH_REGISTER_SUCCESS, {
|
||||||
|
"user": get_current_user()
|
||||||
|
})
|
||||||
|
register_succeeded.emit(get_current_user())
|
||||||
|
auth_state_changed.emit(true, get_current_user())
|
||||||
|
_refresh_player_snapshot()
|
||||||
|
|
||||||
|
func _clear_pending_registration() -> void:
|
||||||
|
_pending_registration_username = ""
|
||||||
|
_pending_registration_password = ""
|
||||||
|
_registration_recovery_in_flight = false
|
||||||
|
_registration_recovery_error = ""
|
||||||
|
|
||||||
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()
|
||||||
|
|
||||||
@@ -322,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:
|
||||||
@@ -334,6 +449,38 @@ func _apply_auth_payload(payload: Dictionary) -> void:
|
|||||||
_session_generation += 1
|
_session_generation += 1
|
||||||
_save_cached_session()
|
_save_cached_session()
|
||||||
|
|
||||||
|
func _try_import_browser_bootstrap(emit_signal: bool) -> bool:
|
||||||
|
if OS.get_name() != "Web" or not Engine.has_singleton("JavaScriptBridge"):
|
||||||
|
return false
|
||||||
|
var js_bridge: Object = Engine.get_singleton("JavaScriptBridge")
|
||||||
|
if js_bridge == null:
|
||||||
|
return false
|
||||||
|
var raw: String = str(js_bridge.eval(
|
||||||
|
"window.sessionStorage.getItem('%s') || ''" % BROWSER_BOOTSTRAP_STORAGE_KEY,
|
||||||
|
true
|
||||||
|
)).strip_edges()
|
||||||
|
if raw.is_empty():
|
||||||
|
return false
|
||||||
|
var json := JSON.new()
|
||||||
|
if json.parse(raw) != OK or not (json.data is Dictionary):
|
||||||
|
js_bridge.eval("window.sessionStorage.removeItem('%s')" % BROWSER_BOOTSTRAP_STORAGE_KEY)
|
||||||
|
return false
|
||||||
|
var bootstrap := json.data as Dictionary
|
||||||
|
var kind := str(bootstrap.get("kind", "")).strip_edges()
|
||||||
|
var payload_variant: Variant = bootstrap.get("payload", {})
|
||||||
|
if not ["login", "register"].has(kind) or not (payload_variant is Dictionary):
|
||||||
|
js_bridge.eval("window.sessionStorage.removeItem('%s')" % BROWSER_BOOTSTRAP_STORAGE_KEY)
|
||||||
|
return false
|
||||||
|
_apply_auth_payload(payload_variant as Dictionary)
|
||||||
|
if not is_authenticated():
|
||||||
|
return false
|
||||||
|
_browser_bootstrap_kind = kind
|
||||||
|
js_bridge.eval("window.sessionStorage.removeItem('%s')" % BROWSER_BOOTSTRAP_STORAGE_KEY)
|
||||||
|
if emit_signal:
|
||||||
|
browser_bootstrap_received.emit(kind)
|
||||||
|
auth_state_changed.emit(true, get_current_user())
|
||||||
|
return true
|
||||||
|
|
||||||
func _on_profile_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void:
|
func _on_profile_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void:
|
||||||
if not success:
|
if not success:
|
||||||
profile_update_failed.emit(str(_error_info.get("message", "玩家资料保存失败")))
|
profile_update_failed.emit(str(_error_info.get("message", "玩家资料保存失败")))
|
||||||
@@ -353,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
|
||||||
@@ -426,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()
|
||||||
|
|
||||||
@@ -441,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()
|
||||||
@@ -497,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:
|
||||||
|
|||||||
@@ -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", {})))),
|
||||||
}
|
}
|
||||||
|
|||||||
201
_Core/managers/InputFocusManager.gd
Normal 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
|
||||||
1
_Core/managers/InputFocusManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://lg8ud3kgjliv
|
||||||
@@ -30,6 +30,9 @@ signal scene_changed(scene_name: String)
|
|||||||
# 场景切换开始信号
|
# 场景切换开始信号
|
||||||
# 参数: scene_name - 即将切换到的场景名称
|
# 参数: scene_name - 即将切换到的场景名称
|
||||||
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_failed(scene_name: String, message: String)
|
||||||
|
signal scene_pack_load_finished(pack_key: String, success: bool)
|
||||||
|
|
||||||
# ============ 成员变量 ============
|
# ============ 成员变量 ============
|
||||||
|
|
||||||
@@ -38,6 +41,38 @@ var current_scene_name: String = "" # 当前场景名称
|
|||||||
var is_changing_scene: bool = false # 是否正在切换场景
|
var is_changing_scene: bool = false # 是否正在切换场景
|
||||||
var _next_scene_position: Variant = null # 下一个场景的初始位置 (Vector2 or null)
|
var _next_scene_position: Variant = null # 下一个场景的初始位置 (Vector2 or null)
|
||||||
var _next_spawn_name: String = "" # 下一个场景的出生点名称 (String)
|
var _next_spawn_name: String = "" # 下一个场景的出生点名称 (String)
|
||||||
|
var _pack_manifest: Dictionary = {}
|
||||||
|
var _loaded_scene_packs: Dictionary = {}
|
||||||
|
var _loading_scene_packs: Dictionary = {}
|
||||||
|
var _scene_pack_prefetch_queue: Array[String] = []
|
||||||
|
var _scene_pack_prefetch_running: bool = false
|
||||||
|
var _active_pack_request: HTTPRequest
|
||||||
|
var _active_pack_scene_name: String = ""
|
||||||
|
var _active_pack_expected_size: int = 0
|
||||||
|
var _pack_overlay: CanvasLayer
|
||||||
|
var _pack_status_label: Label
|
||||||
|
var _pack_progress_bar: ProgressBar
|
||||||
|
var _web_pack_progress_last_emit_msec: int = 0
|
||||||
|
|
||||||
|
const PACK_MANIFEST_PATH: String = "packs/manifest.json"
|
||||||
|
const PACK_CACHE_DIR: String = "user://scene-packs"
|
||||||
|
const SCENE_PACK_KEYS: Dictionary = {
|
||||||
|
"main": "auth",
|
||||||
|
"auth": "auth",
|
||||||
|
"square": "square",
|
||||||
|
"work_zone": "work_zone",
|
||||||
|
"cafe_interior": "cafe_interior",
|
||||||
|
"room": "personal_space",
|
||||||
|
"personal_space": "personal_space",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 只预取当前地图可以直接到达的区域。
|
||||||
|
# 顺序很重要:登录进入广场后先准备个人空间,再准备打工区;
|
||||||
|
# 咖啡馆只有在进入打工区后才加入队列。
|
||||||
|
const SCENE_PREFETCH_ROUTES: Dictionary = {
|
||||||
|
"square": ["personal_space", "work_zone"],
|
||||||
|
"work_zone": ["cafe_interior"],
|
||||||
|
}
|
||||||
|
|
||||||
# 场景路径映射表
|
# 场景路径映射表
|
||||||
# 将场景名称映射到实际的文件路径
|
# 将场景名称映射到实际的文件路径
|
||||||
@@ -101,6 +136,14 @@ func change_scene(scene_name: String, use_transition: bool = true):
|
|||||||
if use_transition:
|
if use_transition:
|
||||||
await show_transition()
|
await show_transition()
|
||||||
|
|
||||||
|
if OS.get_name() == "Web" and SCENE_PACK_KEYS.has(scene_name):
|
||||||
|
var packLoaded := await _ensure_scene_pack(scene_name)
|
||||||
|
if not packLoaded:
|
||||||
|
is_changing_scene = false
|
||||||
|
if use_transition:
|
||||||
|
await hide_transition()
|
||||||
|
return false
|
||||||
|
|
||||||
# 执行场景切换
|
# 执行场景切换
|
||||||
var error = get_tree().change_scene_to_file(scene_path)
|
var error = get_tree().change_scene_to_file(scene_path)
|
||||||
if error != OK:
|
if error != OK:
|
||||||
@@ -112,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:
|
||||||
@@ -119,6 +163,272 @@ func change_scene(scene_name: String, use_transition: bool = true):
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
if not is_instance_valid(_active_pack_request):
|
||||||
|
return
|
||||||
|
var downloaded := _active_pack_request.get_downloaded_bytes()
|
||||||
|
var total := _active_pack_request.get_body_size()
|
||||||
|
if total <= 0:
|
||||||
|
total = _active_pack_expected_size
|
||||||
|
if is_instance_valid(_pack_progress_bar):
|
||||||
|
_pack_progress_bar.indeterminate = total <= 0
|
||||||
|
if total > 0:
|
||||||
|
_pack_progress_bar.max_value = total
|
||||||
|
_pack_progress_bar.value = downloaded
|
||||||
|
if is_instance_valid(_pack_status_label):
|
||||||
|
_pack_status_label.text = _format_scene_loading_status(downloaded, total)
|
||||||
|
_notify_web_auth_pack_progress(downloaded, total)
|
||||||
|
scene_pack_progress.emit(_active_pack_scene_name, downloaded, total)
|
||||||
|
|
||||||
|
func _notify_web_auth_pack_progress(downloaded: int, total: int) -> void:
|
||||||
|
if OS.get_name() != "Web" or _active_pack_scene_name != "auth":
|
||||||
|
return
|
||||||
|
var now := Time.get_ticks_msec()
|
||||||
|
if now - _web_pack_progress_last_emit_msec < 250:
|
||||||
|
return
|
||||||
|
_web_pack_progress_last_emit_msec = now
|
||||||
|
if not Engine.has_singleton("JavaScriptBridge"):
|
||||||
|
return
|
||||||
|
var bridge: Object = Engine.get_singleton("JavaScriptBridge")
|
||||||
|
bridge.eval("window.whaletownPackProgress && window.whaletownPackProgress(%d, %d)" % [downloaded, total], true)
|
||||||
|
|
||||||
|
func _ensure_scene_pack(scene_name: String) -> bool:
|
||||||
|
var packKey := str(SCENE_PACK_KEYS.get(scene_name, ""))
|
||||||
|
if packKey.is_empty() or bool(_loaded_scene_packs.get(packKey, false)):
|
||||||
|
return true
|
||||||
|
_show_pack_overlay("正在准备场景...")
|
||||||
|
# 场景切换也进入同一个预取队列,避免用户点击入口时与后台下载并发。
|
||||||
|
_queue_scene_pack_prefetch([scene_name])
|
||||||
|
while not bool(_loaded_scene_packs.get(packKey, false)):
|
||||||
|
if not bool(_loading_scene_packs.get(packKey, false)) and not _scene_pack_prefetch_queue.has(scene_name):
|
||||||
|
break
|
||||||
|
await scene_pack_load_finished
|
||||||
|
if bool(_loaded_scene_packs.get(packKey, false)):
|
||||||
|
_hide_pack_overlay()
|
||||||
|
return true
|
||||||
|
_show_pack_error(scene_name, "场景下载失败,请检查网络后重试")
|
||||||
|
return false
|
||||||
|
|
||||||
|
func preload_scene_pack(scene_name: String) -> void:
|
||||||
|
if OS.get_name() != "Web":
|
||||||
|
return
|
||||||
|
_queue_scene_pack_prefetch([scene_name])
|
||||||
|
|
||||||
|
func _schedule_scene_prefetch(scene_name: String) -> void:
|
||||||
|
if OS.get_name() != "Web" or not _should_prefetch_scene_packs():
|
||||||
|
return
|
||||||
|
var routeVariant: Variant = SCENE_PREFETCH_ROUTES.get(scene_name, [])
|
||||||
|
if routeVariant is Array and not (routeVariant as Array).is_empty():
|
||||||
|
_queue_scene_pack_prefetch(routeVariant as Array)
|
||||||
|
|
||||||
|
func _should_prefetch_scene_packs() -> bool:
|
||||||
|
var authManager := get_node_or_null("/root/AuthManager")
|
||||||
|
if authManager == null or not authManager.has_method("is_authenticated"):
|
||||||
|
return false
|
||||||
|
if not bool(authManager.call("is_authenticated")):
|
||||||
|
return false
|
||||||
|
var chatManager := get_node_or_null("/root/ChatManager")
|
||||||
|
return chatManager == null or not chatManager.has_method("is_guest_mode") or not bool(chatManager.call("is_guest_mode"))
|
||||||
|
|
||||||
|
func _queue_scene_pack_prefetch(scene_names: Array) -> void:
|
||||||
|
for sceneNameVariant in scene_names:
|
||||||
|
var sceneName := str(sceneNameVariant).strip_edges()
|
||||||
|
var packKey := str(SCENE_PACK_KEYS.get(sceneName, ""))
|
||||||
|
if packKey.is_empty() or bool(_loaded_scene_packs.get(packKey, false)) or bool(_loading_scene_packs.get(packKey, false)):
|
||||||
|
continue
|
||||||
|
if _scene_pack_prefetch_queue.has(sceneName):
|
||||||
|
continue
|
||||||
|
_scene_pack_prefetch_queue.append(sceneName)
|
||||||
|
_run_scene_pack_prefetch_queue()
|
||||||
|
|
||||||
|
func _run_scene_pack_prefetch_queue() -> void:
|
||||||
|
if _scene_pack_prefetch_running:
|
||||||
|
return
|
||||||
|
_scene_pack_prefetch_running = true
|
||||||
|
while not _scene_pack_prefetch_queue.is_empty():
|
||||||
|
var sceneName: String = str(_scene_pack_prefetch_queue.pop_front())
|
||||||
|
var packKey := str(SCENE_PACK_KEYS.get(sceneName, ""))
|
||||||
|
if packKey.is_empty() or bool(_loaded_scene_packs.get(packKey, false)):
|
||||||
|
continue
|
||||||
|
if not bool(_loading_scene_packs.get(packKey, false)):
|
||||||
|
_start_scene_pack_load(sceneName, packKey)
|
||||||
|
while bool(_loading_scene_packs.get(packKey, false)):
|
||||||
|
await scene_pack_load_finished
|
||||||
|
_scene_pack_prefetch_running = false
|
||||||
|
|
||||||
|
func _start_scene_pack_load(scene_name: String, pack_key: String) -> void:
|
||||||
|
_loading_scene_packs[pack_key] = true
|
||||||
|
_load_scene_pack_task(scene_name, pack_key)
|
||||||
|
|
||||||
|
func _load_scene_pack_task(scene_name: String, pack_key: String) -> void:
|
||||||
|
var success := await _load_scene_pack_file(scene_name, pack_key)
|
||||||
|
if success:
|
||||||
|
_loaded_scene_packs[pack_key] = true
|
||||||
|
_loading_scene_packs.erase(pack_key)
|
||||||
|
scene_pack_load_finished.emit(pack_key, success)
|
||||||
|
|
||||||
|
func _load_scene_pack_file(scene_name: String, pack_key: String) -> bool:
|
||||||
|
if _pack_manifest.is_empty():
|
||||||
|
_pack_manifest = await _download_pack_manifest()
|
||||||
|
if _pack_manifest.is_empty():
|
||||||
|
return false
|
||||||
|
var entryVariant: Variant = _pack_manifest.get(pack_key, {})
|
||||||
|
if not (entryVariant is Dictionary):
|
||||||
|
return false
|
||||||
|
var entry := entryVariant as Dictionary
|
||||||
|
var fileName := str(entry.get("file", "")).get_file()
|
||||||
|
if fileName.is_empty() or not fileName.ends_with(".pck"):
|
||||||
|
return false
|
||||||
|
var expectedSize := int(entry.get("size", 0))
|
||||||
|
var cacheDirAbsolute := ProjectSettings.globalize_path(PACK_CACHE_DIR)
|
||||||
|
DirAccess.make_dir_recursive_absolute(cacheDirAbsolute)
|
||||||
|
var localPath := "%s/%s" % [PACK_CACHE_DIR, fileName]
|
||||||
|
if _is_scene_pack_file_valid(localPath, fileName, expectedSize) and ProjectSettings.load_resource_pack(localPath, true):
|
||||||
|
return true
|
||||||
|
if FileAccess.file_exists(localPath):
|
||||||
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath))
|
||||||
|
var packUrl := _resolve_web_url("packs/%s" % fileName)
|
||||||
|
if packUrl.is_empty() or not await _download_pack(scene_name, packUrl, localPath, expectedSize):
|
||||||
|
return false
|
||||||
|
if not _is_scene_pack_file_valid(localPath, fileName, expectedSize):
|
||||||
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath))
|
||||||
|
return false
|
||||||
|
if not ProjectSettings.load_resource_pack(localPath, true):
|
||||||
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath))
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
func _is_scene_pack_file_valid(local_path: String, file_name: String, expected_size: int) -> bool:
|
||||||
|
if not FileAccess.file_exists(local_path):
|
||||||
|
return false
|
||||||
|
var file := FileAccess.open(local_path, FileAccess.READ)
|
||||||
|
if file == null:
|
||||||
|
return false
|
||||||
|
var actualSize := file.get_length()
|
||||||
|
file.close()
|
||||||
|
if expected_size > 0 and actualSize != expected_size:
|
||||||
|
return false
|
||||||
|
var stem := file_name.trim_suffix(".pck")
|
||||||
|
var separator := stem.rfind("-")
|
||||||
|
if separator < 0:
|
||||||
|
return true
|
||||||
|
var expectedHashPrefix := stem.substr(separator + 1).to_lower()
|
||||||
|
if expectedHashPrefix.length() != 12:
|
||||||
|
return true
|
||||||
|
var actualHash := FileAccess.get_sha256(local_path).to_lower()
|
||||||
|
return not actualHash.is_empty() and actualHash.begins_with(expectedHashPrefix)
|
||||||
|
|
||||||
|
func _download_pack_manifest() -> Dictionary:
|
||||||
|
var request := HTTPRequest.new()
|
||||||
|
request.timeout = 12.0
|
||||||
|
add_child(request)
|
||||||
|
var url := _resolve_web_url(PACK_MANIFEST_PATH)
|
||||||
|
if url.is_empty() or request.request(url, ["Cache-Control: no-cache"]) != OK:
|
||||||
|
request.queue_free()
|
||||||
|
return {}
|
||||||
|
var response: Array = await request.request_completed
|
||||||
|
request.queue_free()
|
||||||
|
if response.size() < 4 or int(response[0]) != HTTPRequest.RESULT_SUCCESS:
|
||||||
|
return {}
|
||||||
|
var responseCode := int(response[1])
|
||||||
|
if responseCode < 200 or responseCode >= 300:
|
||||||
|
return {}
|
||||||
|
var json := JSON.new()
|
||||||
|
if json.parse((response[3] as PackedByteArray).get_string_from_utf8()) != OK:
|
||||||
|
return {}
|
||||||
|
if not (json.data is Dictionary):
|
||||||
|
return {}
|
||||||
|
var root := json.data as Dictionary
|
||||||
|
var packsVariant: Variant = root.get("packs", {})
|
||||||
|
return packsVariant as Dictionary if packsVariant is Dictionary else {}
|
||||||
|
|
||||||
|
func _download_pack(scene_name: String, url: String, local_path: String, expected_size: int) -> bool:
|
||||||
|
var request := HTTPRequest.new()
|
||||||
|
request.timeout = 900.0
|
||||||
|
request.download_file = local_path
|
||||||
|
add_child(request)
|
||||||
|
_active_pack_request = request
|
||||||
|
_active_pack_scene_name = scene_name
|
||||||
|
_active_pack_expected_size = expected_size
|
||||||
|
if request.request(url) != OK:
|
||||||
|
_active_pack_request = null
|
||||||
|
_active_pack_scene_name = ""
|
||||||
|
_active_pack_expected_size = 0
|
||||||
|
request.queue_free()
|
||||||
|
return false
|
||||||
|
var response: Array = await request.request_completed
|
||||||
|
_active_pack_request = null
|
||||||
|
_active_pack_scene_name = ""
|
||||||
|
_active_pack_expected_size = 0
|
||||||
|
request.queue_free()
|
||||||
|
if response.size() < 2 or int(response[0]) != HTTPRequest.RESULT_SUCCESS:
|
||||||
|
return false
|
||||||
|
var responseCode := int(response[1])
|
||||||
|
return responseCode >= 200 and responseCode < 300
|
||||||
|
|
||||||
|
func _resolve_web_url(path: String) -> String:
|
||||||
|
if not Engine.has_singleton("JavaScriptBridge"):
|
||||||
|
return ""
|
||||||
|
var jsBridge: Object = Engine.get_singleton("JavaScriptBridge")
|
||||||
|
if jsBridge == null:
|
||||||
|
return ""
|
||||||
|
return str(jsBridge.eval("new URL('%s', window.location.href).href" % path, true)).strip_edges()
|
||||||
|
|
||||||
|
func _show_pack_overlay(message: String) -> void:
|
||||||
|
if not is_instance_valid(_pack_overlay):
|
||||||
|
_pack_overlay = CanvasLayer.new()
|
||||||
|
_pack_overlay.layer = 1000
|
||||||
|
add_child(_pack_overlay)
|
||||||
|
var backdrop := ColorRect.new()
|
||||||
|
backdrop.color = Color(0.025, 0.09, 0.15, 0.92)
|
||||||
|
backdrop.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
_pack_overlay.add_child(backdrop)
|
||||||
|
var center := CenterContainer.new()
|
||||||
|
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
_pack_overlay.add_child(center)
|
||||||
|
var box := VBoxContainer.new()
|
||||||
|
box.custom_minimum_size = Vector2(420, 100)
|
||||||
|
box.add_theme_constant_override("separation", 18)
|
||||||
|
center.add_child(box)
|
||||||
|
_pack_status_label = Label.new()
|
||||||
|
_pack_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
_pack_status_label.add_theme_font_size_override("font_size", 22)
|
||||||
|
box.add_child(_pack_status_label)
|
||||||
|
_pack_progress_bar = ProgressBar.new()
|
||||||
|
_pack_progress_bar.custom_minimum_size = Vector2(420, 14)
|
||||||
|
_pack_progress_bar.show_percentage = false
|
||||||
|
_pack_progress_bar.indeterminate = true
|
||||||
|
box.add_child(_pack_progress_bar)
|
||||||
|
_pack_overlay.show()
|
||||||
|
_pack_status_label.text = message
|
||||||
|
_pack_progress_bar.indeterminate = true
|
||||||
|
|
||||||
|
func _hide_pack_overlay() -> void:
|
||||||
|
if is_instance_valid(_pack_overlay):
|
||||||
|
_pack_overlay.hide()
|
||||||
|
|
||||||
|
func _show_pack_error(scene_name: String, message: String) -> void:
|
||||||
|
if not is_instance_valid(_pack_overlay):
|
||||||
|
_show_pack_overlay(message)
|
||||||
|
_pack_status_label.text = message
|
||||||
|
_pack_progress_bar.hide()
|
||||||
|
scene_pack_failed.emit(scene_name, message)
|
||||||
|
await get_tree().create_timer(3.0).timeout
|
||||||
|
_pack_progress_bar.show()
|
||||||
|
_hide_pack_overlay()
|
||||||
|
|
||||||
|
func _format_download_progress(downloaded: int, total: int) -> String:
|
||||||
|
var downloadedMb := float(downloaded) / 1048576.0
|
||||||
|
if total <= 0:
|
||||||
|
return "%.1f MB" % downloadedMb
|
||||||
|
return "%.1f / %.1f MB" % [downloadedMb, float(total) / 1048576.0]
|
||||||
|
|
||||||
|
func _format_scene_loading_status(downloaded: int, total: int) -> String:
|
||||||
|
if total <= 0:
|
||||||
|
return "正在加载场景..."
|
||||||
|
var percent: int = mini(100, int(round(float(downloaded) / float(total) * 100.0)))
|
||||||
|
return "正在加载场景 %d%%(%s)" % [percent, _format_download_progress(downloaded, total)]
|
||||||
|
|
||||||
# ============ 查询方法 ============
|
# ============ 查询方法 ============
|
||||||
|
|
||||||
# 获取当前场景名称
|
# 获取当前场景名称
|
||||||
|
|||||||
26
_Core/managers/UIFontManager.gd
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
# Enforce a Chinese-capable font for every UI control, including controls
|
||||||
|
# constructed dynamically after their scene has loaded.
|
||||||
|
const UI_FONT: FontFile = preload("res://assets/fonts/msyh-web.ttf")
|
||||||
|
|
||||||
|
func _enter_tree() -> void:
|
||||||
|
get_tree().node_added.connect(_on_node_added)
|
||||||
|
_apply_font_recursively(get_tree().root)
|
||||||
|
|
||||||
|
func _exit_tree() -> void:
|
||||||
|
var node_added_callback := Callable(self, "_on_node_added")
|
||||||
|
if get_tree().node_added.is_connected(node_added_callback):
|
||||||
|
get_tree().node_added.disconnect(node_added_callback)
|
||||||
|
|
||||||
|
func _on_node_added(node: Node) -> void:
|
||||||
|
_apply_font_recursively(node)
|
||||||
|
|
||||||
|
func _apply_font_recursively(node: Node) -> void:
|
||||||
|
if node is Control:
|
||||||
|
var control := node as Control
|
||||||
|
control.add_theme_font_override(&"font", UI_FONT)
|
||||||
|
if control is RichTextLabel:
|
||||||
|
control.add_theme_font_override(&"normal_font", UI_FONT)
|
||||||
|
for child in node.get_children():
|
||||||
|
_apply_font_recursively(child)
|
||||||
1
_Core/managers/UIFontManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cvb5smbfhqnfc
|
||||||
@@ -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 "错误"
|
||||||
_:
|
_:
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ const CONFIG_PATHS: Array[String] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const DEFAULT_PROFILE: String = "production"
|
const DEFAULT_PROFILE: String = "production"
|
||||||
const DEFAULT_API_BASE_URL: String = "https://whaletownend.xinghangee.icu"
|
const DEFAULT_API_BASE_URL: String = "https://whaletown.novamailio.com/api"
|
||||||
const DEFAULT_WS_URL: String = "wss://whaletownend.xinghangee.icu/game"
|
const DEFAULT_WS_URL: String = "wss://whaletown.novamailio.com/game"
|
||||||
|
|
||||||
const API_BASE_URL_ENV_KEY: String = "WHALETOWN_API_BASE_URL"
|
const API_BASE_URL_ENV_KEY: String = "WHALETOWN_API_BASE_URL"
|
||||||
const CHAT_WS_URL_ENV_KEY: String = "WHALETOWN_CHAT_WS_URL"
|
const CHAT_WS_URL_ENV_KEY: String = "WHALETOWN_CHAT_WS_URL"
|
||||||
@@ -35,6 +35,10 @@ static func get_api_base_url() -> String:
|
|||||||
if not env_url.is_empty():
|
if not env_url.is_empty():
|
||||||
return _trim_trailing_slash(env_url)
|
return _trim_trailing_slash(env_url)
|
||||||
|
|
||||||
|
var browser_url := _get_browser_same_origin_url("/api")
|
||||||
|
if not browser_url.is_empty():
|
||||||
|
return browser_url
|
||||||
|
|
||||||
var network_config := _get_active_network_config()
|
var network_config := _get_active_network_config()
|
||||||
var url: String = str(network_config.get("api_base_url", "")).strip_edges()
|
var url: String = str(network_config.get("api_base_url", "")).strip_edges()
|
||||||
if not url.is_empty():
|
if not url.is_empty():
|
||||||
@@ -51,6 +55,10 @@ static func get_chat_ws_url() -> String:
|
|||||||
if not env_url.is_empty():
|
if not env_url.is_empty():
|
||||||
return env_url
|
return env_url
|
||||||
|
|
||||||
|
var browser_url := _get_browser_same_origin_websocket_url("/game")
|
||||||
|
if not browser_url.is_empty():
|
||||||
|
return browser_url
|
||||||
|
|
||||||
var network_config := _get_active_network_config()
|
var network_config := _get_active_network_config()
|
||||||
var url: String = str(network_config.get("chat_ws_url", network_config.get("game_ws_url", ""))).strip_edges()
|
var url: String = str(network_config.get("chat_ws_url", network_config.get("game_ws_url", ""))).strip_edges()
|
||||||
if not url.is_empty():
|
if not url.is_empty():
|
||||||
@@ -75,6 +83,10 @@ static func get_location_ws_url() -> String:
|
|||||||
if not game_env_url.is_empty():
|
if not game_env_url.is_empty():
|
||||||
return game_env_url
|
return game_env_url
|
||||||
|
|
||||||
|
var browser_url := _get_browser_same_origin_websocket_url("/game")
|
||||||
|
if not browser_url.is_empty():
|
||||||
|
return browser_url
|
||||||
|
|
||||||
var network_config := _get_active_network_config()
|
var network_config := _get_active_network_config()
|
||||||
var url: String = str(network_config.get("location_ws_url", network_config.get("game_ws_url", ""))).strip_edges()
|
var url: String = str(network_config.get("location_ws_url", network_config.get("game_ws_url", ""))).strip_edges()
|
||||||
if not url.is_empty():
|
if not url.is_empty():
|
||||||
@@ -154,7 +166,7 @@ static func _load_config() -> Dictionary:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
static func _get_browser_query_value(key: String) -> String:
|
static func _get_browser_query_value(key: String) -> String:
|
||||||
if OS.get_name() != "Web" or not OS.is_debug_build():
|
if OS.get_name() != "Web":
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
if not Engine.has_singleton("JavaScriptBridge"):
|
if not Engine.has_singleton("JavaScriptBridge"):
|
||||||
@@ -168,6 +180,25 @@ static func _get_browser_query_value(key: String) -> String:
|
|||||||
var value: Variant = js_bridge.eval(script, true)
|
var value: Variant = js_bridge.eval(script, true)
|
||||||
return str(value).strip_edges()
|
return str(value).strip_edges()
|
||||||
|
|
||||||
|
static func _get_browser_same_origin_url(path: String) -> String:
|
||||||
|
if OS.get_name() != "Web" or not Engine.has_singleton("JavaScriptBridge"):
|
||||||
|
return ""
|
||||||
|
var js_bridge: Object = Engine.get_singleton("JavaScriptBridge")
|
||||||
|
if js_bridge == null:
|
||||||
|
return ""
|
||||||
|
var origin: String = str(js_bridge.eval("window.location.origin || ''", true)).strip_edges()
|
||||||
|
if not origin.begins_with("http://") and not origin.begins_with("https://"):
|
||||||
|
return ""
|
||||||
|
return _trim_trailing_slash(origin) + path
|
||||||
|
|
||||||
|
static func _get_browser_same_origin_websocket_url(path: String) -> String:
|
||||||
|
var http_url := _get_browser_same_origin_url("")
|
||||||
|
if http_url.begins_with("https://"):
|
||||||
|
return "wss://" + http_url.substr(8) + path
|
||||||
|
if http_url.begins_with("http://"):
|
||||||
|
return "ws://" + http_url.substr(7) + path
|
||||||
|
return ""
|
||||||
|
|
||||||
static func _get_browser_url_override(key: String, allowed_schemes: Array[String]) -> String:
|
static func _get_browser_url_override(key: String, allowed_schemes: Array[String]) -> String:
|
||||||
var value := _get_browser_query_value(key)
|
var value := _get_browser_query_value(key)
|
||||||
if value.is_empty():
|
if value.is_empty():
|
||||||
|
|||||||
123
_Core/utils/WebFilePicker.gd
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
var _javascript_callback: Variant
|
||||||
|
var _result_callback: Callable
|
||||||
|
var _callback_name: String = ""
|
||||||
|
|
||||||
|
func open(accept: String, max_bytes: int, result_callback: Callable) -> bool:
|
||||||
|
if OS.get_name() != "Web" or not Engine.has_singleton("JavaScriptBridge"):
|
||||||
|
return false
|
||||||
|
if _result_callback.is_valid():
|
||||||
|
return false
|
||||||
|
|
||||||
|
var javascript_bridge: Object = Engine.get_singleton("JavaScriptBridge")
|
||||||
|
_callback_name = "__whaletownFilePicker%d" % get_instance_id()
|
||||||
|
_result_callback = result_callback
|
||||||
|
_javascript_callback = javascript_bridge.create_callback(_on_javascript_result)
|
||||||
|
var window: Variant = javascript_bridge.get_interface("window")
|
||||||
|
if window == null:
|
||||||
|
_reset()
|
||||||
|
return false
|
||||||
|
window[_callback_name] = _javascript_callback
|
||||||
|
|
||||||
|
var script := """
|
||||||
|
(() => {
|
||||||
|
const callbackName = %s;
|
||||||
|
const callback = window[callbackName];
|
||||||
|
if (typeof callback !== "function") return false;
|
||||||
|
|
||||||
|
const previous = document.getElementById("whaletown-web-file-picker");
|
||||||
|
if (previous) previous.remove();
|
||||||
|
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.id = "whaletown-web-file-picker";
|
||||||
|
input.type = "file";
|
||||||
|
input.accept = %s;
|
||||||
|
input.style.display = "none";
|
||||||
|
let finished = false;
|
||||||
|
|
||||||
|
const finish = (status, name, mime, payload) => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
callback(status, name || "", mime || "", payload || "");
|
||||||
|
input.remove();
|
||||||
|
delete window[callbackName];
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener("cancel", () => finish("cancel", "", "", ""));
|
||||||
|
input.addEventListener("change", () => {
|
||||||
|
const file = input.files && input.files[0];
|
||||||
|
if (!file) {
|
||||||
|
finish("cancel", "", "", "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > %d) {
|
||||||
|
finish("too_large", file.name, file.type, "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onerror = () => finish("read_error", file.name, file.type, "");
|
||||||
|
reader.onload = () => {
|
||||||
|
const bytes = new Uint8Array(reader.result);
|
||||||
|
const chunks = [];
|
||||||
|
const chunkSize = 0x8000;
|
||||||
|
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||||
|
chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)));
|
||||||
|
}
|
||||||
|
finish("ok", file.name, file.type, btoa(chunks.join("")));
|
||||||
|
};
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.click();
|
||||||
|
return true;
|
||||||
|
})()
|
||||||
|
""" % [JSON.stringify(_callback_name), JSON.stringify(accept), max_bytes]
|
||||||
|
var opened: Variant = javascript_bridge.eval(script, true)
|
||||||
|
if opened != true:
|
||||||
|
_reset()
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
func _on_javascript_result(arguments: Array) -> void:
|
||||||
|
var callback := _result_callback
|
||||||
|
_reset()
|
||||||
|
if not callback.is_valid():
|
||||||
|
return
|
||||||
|
var status := str(arguments[0]) if arguments.size() > 0 else "read_error"
|
||||||
|
var file_name := str(arguments[1]) if arguments.size() > 1 else ""
|
||||||
|
var mime_type := str(arguments[2]) if arguments.size() > 2 else ""
|
||||||
|
var base64_data := str(arguments[3]) if arguments.size() > 3 else ""
|
||||||
|
callback.call(status, file_name, mime_type, base64_data)
|
||||||
|
|
||||||
|
func _reset() -> void:
|
||||||
|
_javascript_callback = null
|
||||||
|
_result_callback = Callable()
|
||||||
|
_callback_name = ""
|
||||||
|
|
||||||
|
static func save_base64_file(base64_data: String, file_name: String, prefix: String) -> String:
|
||||||
|
if base64_data.is_empty():
|
||||||
|
return ""
|
||||||
|
var bytes := Marshalls.base64_to_raw(base64_data)
|
||||||
|
if bytes.is_empty():
|
||||||
|
return ""
|
||||||
|
|
||||||
|
var extension := file_name.get_extension().to_lower()
|
||||||
|
if not ["png", "jpg", "jpeg", "webp"].has(extension):
|
||||||
|
extension = "bin"
|
||||||
|
var root := DirAccess.open("user://")
|
||||||
|
if root == null:
|
||||||
|
return ""
|
||||||
|
if not root.dir_exists("web_uploads") and root.make_dir("web_uploads") != OK:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
var safe_prefix := prefix.to_lower().replace(" ", "_")
|
||||||
|
var path := "user://web_uploads/%s_%d.%s" % [safe_prefix, Time.get_ticks_msec(), extension]
|
||||||
|
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||||
|
if file == null:
|
||||||
|
return ""
|
||||||
|
file.store_buffer(bytes)
|
||||||
|
file.close()
|
||||||
|
return path
|
||||||
1
_Core/utils/WebFilePicker.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dqvrn7yv0ap4y
|
||||||
|
After Width: | Height: | Size: 226 KiB |
@@ -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
|
||||||
|
After Width: | Height: | Size: 165 KiB |
@@ -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
|
||||||
|
After Width: | Height: | Size: 177 KiB |
@@ -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
|
||||||
|
After Width: | Height: | Size: 198 KiB |
@@ -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
|
||||||
|
After Width: | Height: | Size: 784 KiB |
@@ -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
|
||||||
|
After Width: | Height: | Size: 290 KiB |
@@ -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
|
||||||
|
Before Width: | Height: | Size: 398 KiB After Width: | Height: | Size: 576 KiB |
96
assets/fonts/fusion-pixel-12px/OFL.txt
Normal 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.
|
||||||
@@ -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={}
|
||||||
@@ -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={}
|
||||||
BIN
assets/fonts/msyh-web.ttf
Normal file
36
assets/fonts/msyh-web.ttf.import
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="font_data_dynamic"
|
||||||
|
type="FontFile"
|
||||||
|
uid="uid://cy7uxq32lpgou"
|
||||||
|
path="res://.godot/imported/msyh-web.ttf-0b9bbf1e573e29d7e152628e09746417.fontdata"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/fonts/msyh-web.ttf"
|
||||||
|
dest_files=["res://.godot/imported/msyh-web.ttf-0b9bbf1e573e29d7e152628e09746417.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={}
|
||||||
BIN
assets/maps/cafe/whale_cafe_large_service_hall_base_v4.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://cw2ywnvsjk4tw"
|
||||||
|
path="res://.godot/imported/whale_cafe_large_service_hall_base_v4.png-a01976399336db42367bf3da210ce89b.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/maps/cafe/whale_cafe_large_service_hall_base_v4.png"
|
||||||
|
dest_files=["res://.godot/imported/whale_cafe_large_service_hall_base_v4.png-a01976399336db42367bf3da210ce89b.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
|
||||||
15
assets/shaders/avatar_round_mask.gdshader
Normal 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;
|
||||||
|
}
|
||||||
1
assets/shaders/avatar_round_mask.gdshader.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bkudybm8sbvug
|
||||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 58 KiB |
BIN
assets/ui/world_bulletin/community/empty_whale.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
40
assets/ui/world_bulletin/community/empty_whale.png.import
Normal 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
|
||||||
BIN
assets/ui/world_bulletin/community/pinned_note.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
40
assets/ui/world_bulletin/community/pinned_note.png.import
Normal 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
|
||||||
BIN
assets/ui/world_bulletin/community/recruit_whales.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
40
assets/ui/world_bulletin/community/recruit_whales.png.import
Normal 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
|
||||||
BIN
assets/ui/world_bulletin/community/town_banner.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
40
assets/ui/world_bulletin/community/town_banner.png.import
Normal 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
|
||||||
|
After Width: | Height: | Size: 1.0 MiB |
@@ -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
|
||||||
|
After Width: | Height: | Size: 41 KiB |
@@ -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
|
||||||
BIN
assets/ui/world_bulletin/world_bulletin_send_v2_128.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
@@ -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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[gd_resource type="Theme" format=3 uid="uid://brk6ca2npglqc"]
|
[gd_resource type="Theme" format=3 uid="uid://brk6ca2npglqc"]
|
||||||
|
|
||||||
[ext_resource type="FontFile" uid="uid://ce7ujbeobblyr" path="res://assets/fonts/msyh.ttc" id="1_font"]
|
[ext_resource type="FontFile" uid="uid://cy7uxq32lpgou" path="res://assets/fonts/msyh-web.ttf" id="1_font"]
|
||||||
|
|
||||||
[resource]
|
[resource]
|
||||||
resource_local_to_scene = true
|
resource_local_to_scene = true
|
||||||
|
|||||||
24
deploy/Caddyfile.example
Normal 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
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
89
deploy/Caddyfile.rainyun-novamailio
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
89
deploy/Caddyfile.rainyun-test
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ runnable=true
|
|||||||
advanced_options=false
|
advanced_options=false
|
||||||
dedicated_server=false
|
dedicated_server=false
|
||||||
custom_features=""
|
custom_features=""
|
||||||
export_filter="all_resources"
|
export_filter="scenes"
|
||||||
include_filter=""
|
export_files=PackedStringArray("res://scenes/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,assets/shaders/*.gdshader"
|
||||||
exclude_filter=""
|
exclude_filter=""
|
||||||
export_path="build/web/index.html"
|
export_path="build/web/index.html"
|
||||||
patches=PackedStringArray()
|
patches=PackedStringArray()
|
||||||
@@ -27,7 +28,7 @@ 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
|
||||||
html/export_icon=true
|
html/export_icon=true
|
||||||
html/custom_html_shell=""
|
html/custom_html_shell="res://web/progressive_shell.html"
|
||||||
html/head_include=""
|
html/head_include=""
|
||||||
html/canvas_resize_policy=2
|
html/canvas_resize_policy=2
|
||||||
html/focus_canvas_on_start=true
|
html/focus_canvas_on_start=true
|
||||||
@@ -52,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=""
|
||||||
@@ -72,3 +73,218 @@ texture_format/s3tc_bptc=true
|
|||||||
texture_format/etc2_astc=false
|
texture_format/etc2_astc=false
|
||||||
architecture/x86_64=true
|
architecture/x86_64=true
|
||||||
ssh_remote_deploy/enabled=false
|
ssh_remote_deploy/enabled=false
|
||||||
|
|
||||||
|
[preset.2]
|
||||||
|
|
||||||
|
name="Scene Square"
|
||||||
|
platform="Web"
|
||||||
|
runnable=false
|
||||||
|
advanced_options=false
|
||||||
|
dedicated_server=false
|
||||||
|
custom_features=""
|
||||||
|
export_filter="scenes"
|
||||||
|
export_files=PackedStringArray("res://scenes/Maps/square.tscn")
|
||||||
|
include_filter="scenes/Maps/*.gd,scenes/characters/*.gd,scenes/characters/*.tscn,scenes/prefabs/items/*.gd,scenes/prefabs/items/*.tscn,scenes/prefabs/ui/*.gd,scenes/prefabs/ui/*.tscn,scenes/ui/*.gd,scenes/ui/*.tscn,scenes/ui/mall/*.gd,scenes/ui/mall/*.tscn,assets/audio/ui/*.wav,assets/characters/*.png,assets/characters/skins/*.png,assets/fonts/fusion-pixel-12px/*.woff2,assets/ui/*.tres,assets/ui/auth/generated/*.png,assets/ui/datawhale_honor/*.png,assets/ui/world_bulletin/*.png,assets/ui/mall/branding/*.png,assets/ui/mall/icons/processed/*.png,assets/ui/mall/items/*.png,assets/ui/mall/skins/*_product.png,assets/ui/settings/*.png"
|
||||||
|
exclude_filter="assets/maps/work_zone/**/*.png,assets/maps/personal_space/**/*.png,assets/maps/cafe/**/*.png"
|
||||||
|
export_path="build/packs/square.pck"
|
||||||
|
patches=PackedStringArray()
|
||||||
|
encryption_include_filters=""
|
||||||
|
encryption_exclude_filters=""
|
||||||
|
seed=0
|
||||||
|
encrypt_pck=false
|
||||||
|
encrypt_directory=false
|
||||||
|
script_export_mode=2
|
||||||
|
|
||||||
|
[preset.2.options]
|
||||||
|
|
||||||
|
variant/extensions_support=false
|
||||||
|
variant/thread_support=false
|
||||||
|
vram_texture_compression/for_desktop=true
|
||||||
|
vram_texture_compression/for_mobile=false
|
||||||
|
|
||||||
|
[preset.3]
|
||||||
|
|
||||||
|
name="Scene Work Zone"
|
||||||
|
platform="Web"
|
||||||
|
runnable=false
|
||||||
|
advanced_options=false
|
||||||
|
dedicated_server=false
|
||||||
|
custom_features=""
|
||||||
|
export_filter="scenes"
|
||||||
|
export_files=PackedStringArray("res://scenes/Maps/work_zone.tscn")
|
||||||
|
include_filter="scenes/Maps/*.gd,scenes/characters/*.gd,scenes/characters/*.tscn,scenes/prefabs/items/*.gd,scenes/prefabs/items/*.tscn,scenes/prefabs/ui/*.gd,scenes/prefabs/ui/*.tscn,scenes/ui/*.gd,scenes/ui/*.tscn,scenes/ui/mall/*.gd,scenes/ui/mall/*.tscn,assets/characters/*.png,assets/fonts/fusion-pixel-12px/*.woff2,assets/ui/*.tres,assets/ui/auth/generated/*.png,assets/ui/datawhale_honor/*.png"
|
||||||
|
exclude_filter="assets/maps/square/**/*.png,assets/maps/personal_space/**/*.png,assets/maps/cafe/**/*.png"
|
||||||
|
export_path="build/packs/work_zone.pck"
|
||||||
|
patches=PackedStringArray()
|
||||||
|
encryption_include_filters=""
|
||||||
|
encryption_exclude_filters=""
|
||||||
|
seed=0
|
||||||
|
encrypt_pck=false
|
||||||
|
encrypt_directory=false
|
||||||
|
script_export_mode=2
|
||||||
|
|
||||||
|
[preset.3.options]
|
||||||
|
|
||||||
|
variant/extensions_support=false
|
||||||
|
variant/thread_support=false
|
||||||
|
vram_texture_compression/for_desktop=true
|
||||||
|
vram_texture_compression/for_mobile=false
|
||||||
|
|
||||||
|
[preset.4]
|
||||||
|
|
||||||
|
name="Scene Cafe"
|
||||||
|
platform="Web"
|
||||||
|
runnable=false
|
||||||
|
advanced_options=false
|
||||||
|
dedicated_server=false
|
||||||
|
custom_features=""
|
||||||
|
export_filter="scenes"
|
||||||
|
export_files=PackedStringArray("res://scenes/Maps/cafe_interior.tscn")
|
||||||
|
include_filter="scenes/Maps/*.gd,scenes/characters/*.gd,scenes/characters/*.tscn,scenes/prefabs/items/*.gd,scenes/prefabs/items/*.tscn,scenes/prefabs/ui/*.gd,scenes/prefabs/ui/*.tscn,scenes/ui/*.gd,scenes/ui/*.tscn,scenes/ui/mall/*.gd,scenes/ui/mall/*.tscn,assets/characters/*.png,assets/fonts/fusion-pixel-12px/*.woff2,assets/ui/*.tres,assets/ui/auth/generated/*.png,assets/ui/datawhale_honor/*.png"
|
||||||
|
exclude_filter="assets/maps/square/**/*.png,assets/maps/work_zone/**/*.png,assets/maps/personal_space/**/*.png"
|
||||||
|
export_path="build/packs/cafe_interior.pck"
|
||||||
|
patches=PackedStringArray()
|
||||||
|
encryption_include_filters=""
|
||||||
|
encryption_exclude_filters=""
|
||||||
|
seed=0
|
||||||
|
encrypt_pck=false
|
||||||
|
encrypt_directory=false
|
||||||
|
script_export_mode=2
|
||||||
|
|
||||||
|
[preset.4.options]
|
||||||
|
|
||||||
|
variant/extensions_support=false
|
||||||
|
variant/thread_support=false
|
||||||
|
vram_texture_compression/for_desktop=true
|
||||||
|
vram_texture_compression/for_mobile=false
|
||||||
|
|
||||||
|
[preset.5]
|
||||||
|
|
||||||
|
name="Scene Personal Space"
|
||||||
|
platform="Web"
|
||||||
|
runnable=false
|
||||||
|
advanced_options=false
|
||||||
|
dedicated_server=false
|
||||||
|
custom_features=""
|
||||||
|
export_filter="scenes"
|
||||||
|
export_files=PackedStringArray("res://scenes/Maps/personal_space.tscn")
|
||||||
|
include_filter="scenes/Maps/*.gd,scenes/characters/*.gd,scenes/characters/*.tscn,scenes/prefabs/items/*.gd,scenes/prefabs/items/*.tscn,scenes/prefabs/ui/*.gd,scenes/prefabs/ui/*.tscn,scenes/ui/*.gd,scenes/ui/*.tscn,scenes/ui/mall/*.gd,scenes/ui/mall/*.tscn,assets/characters/*.png,assets/fonts/fusion-pixel-12px/*.woff2,assets/ui/*.tres,assets/ui/auth/generated/*.png,assets/ui/datawhale_honor/*.png,assets/maps/personal_space/v1/base/personal_room_25d_sidewalls_wider_not_longer_v1.png,assets/maps/personal_space/v1/decor/*.png"
|
||||||
|
exclude_filter="assets/maps/square/**/*.png,assets/maps/work_zone/**/*.png,assets/maps/cafe/**/*.png"
|
||||||
|
export_path="build/packs/personal_space.pck"
|
||||||
|
patches=PackedStringArray()
|
||||||
|
encryption_include_filters=""
|
||||||
|
encryption_exclude_filters=""
|
||||||
|
seed=0
|
||||||
|
encrypt_pck=false
|
||||||
|
encrypt_directory=false
|
||||||
|
script_export_mode=2
|
||||||
|
|
||||||
|
[preset.5.options]
|
||||||
|
|
||||||
|
variant/extensions_support=false
|
||||||
|
variant/thread_support=false
|
||||||
|
vram_texture_compression/for_desktop=true
|
||||||
|
vram_texture_compression/for_mobile=false
|
||||||
|
|
||||||
|
[preset.6]
|
||||||
|
|
||||||
|
name="Scene Auth"
|
||||||
|
platform="Web"
|
||||||
|
runnable=false
|
||||||
|
advanced_options=false
|
||||||
|
dedicated_server=false
|
||||||
|
custom_features=""
|
||||||
|
export_filter="scenes"
|
||||||
|
export_files=PackedStringArray("res://scenes/ui/AuthScene.tscn")
|
||||||
|
include_filter="scenes/ui/AuthScene.gd,assets/characters/player_pixel_spritesheet.png,assets/characters/skins/*.png,assets/ui/auth/v1/bg_auth_scene.png,assets/ui/auth/redesign/whaletown_login_panel_hd.png,assets/ui/auth/redesign/whaletown_register_panel_compact_hd.png,assets/ui/auth/registration_choice/redesign/main_panel.png,assets/ui/auth/registration_choice/redesign/top_whale.png,assets/ui/auth/registration_choice/redesign/card_base.png,assets/ui/auth/registration_choice/redesign/card_selected.png,assets/ui/auth/registration_choice/redesign/header_pill.png,assets/ui/auth/registration_choice/redesign/primary_button.png,assets/ui/auth/registration_choice/redesign/footer_bar.png,assets/ui/auth/registration_choice/redesign/skin_slot_base.png,assets/ui/auth/registration_choice/redesign/skin_slot_selected.png,assets/ui/auth/registration_choice/redesign/upload_file_box.png,assets/ui/auth/registration_choice/redesign/spritesheet_grid_box.png,assets/ui/auth/registration_choice/redesign/reference_upload_box.png,assets/ui/auth/registration_choice/redesign/image_placeholder_box.png"
|
||||||
|
exclude_filter="assets/maps/**/*.png,assets/ui/mall/**/*.png,assets/ui/settings/*.png"
|
||||||
|
export_path="build/packs/auth.pck"
|
||||||
|
patches=PackedStringArray()
|
||||||
|
encryption_include_filters=""
|
||||||
|
encryption_exclude_filters=""
|
||||||
|
seed=0
|
||||||
|
encrypt_pck=false
|
||||||
|
encrypt_directory=false
|
||||||
|
script_export_mode=2
|
||||||
|
|
||||||
|
[preset.6.options]
|
||||||
|
|
||||||
|
variant/extensions_support=false
|
||||||
|
variant/thread_support=false
|
||||||
|
vram_texture_compression/for_desktop=true
|
||||||
|
vram_texture_compression/for_mobile=false
|
||||||
|
|
||||||
|
[preset.7]
|
||||||
|
|
||||||
|
name="WhaleTown macOS"
|
||||||
|
platform="macOS"
|
||||||
|
runnable=true
|
||||||
|
advanced_options=false
|
||||||
|
dedicated_server=false
|
||||||
|
custom_features=""
|
||||||
|
export_filter="all_resources"
|
||||||
|
include_filter=""
|
||||||
|
exclude_filter="build/**,output/**,docs/**,scripts/**,tools/**,assets/characters/reference/**,assets/characters/skin_generation_references/**,assets/ui/chat/**,assets/ui/auth/registration_choice/generated/**,assets/ui/auth/registration_choice/preview/**,assets/ui/auth/registration_choice/redesign_preview/**,assets/ui/auth/registration_choice/redesign/*_magenta.png*,assets/maps/square/v1/square_base_terrain_v*.png*,assets/maps/work_zone/v1/**,assets/maps/work_zone/v2/**,assets/maps/work_zone/v3/**,assets/maps/work_zone/v4/**,assets/maps/work_zone/v5/**,assets/maps/work_zone/v6/**,assets/maps/work_zone/v7/**,assets/maps/work_zone/v8/**,assets/maps/work_zone/v9/**,assets/maps/work_zone/v10/**,assets/maps/work_zone/v11/**,assets/maps/work_zone/v12/**,assets/maps/work_zone/v16/**,assets/maps/work_zone/v17/**,assets/maps/work_zone/v18/**,assets/maps/work_zone/v19/**,assets/maps/work_zone/v20/**,assets/maps/work_zone/v21/**,assets/maps/work_zone/v22/**,assets/maps/work_zone/v23/**,assets/maps/work_zone/v24/**,assets/maps/work_zone/v25/**"
|
||||||
|
export_path="build/desktop/macos/WhaleTown.app"
|
||||||
|
patches=PackedStringArray()
|
||||||
|
encryption_include_filters=""
|
||||||
|
encryption_exclude_filters=""
|
||||||
|
seed=0
|
||||||
|
encrypt_pck=false
|
||||||
|
encrypt_directory=false
|
||||||
|
script_export_mode=2
|
||||||
|
|
||||||
|
[preset.7.options]
|
||||||
|
|
||||||
|
export/distribution_type=0
|
||||||
|
binary_format/architecture="universal"
|
||||||
|
custom_template/debug=""
|
||||||
|
custom_template/release=""
|
||||||
|
debug/export_console_wrapper=1
|
||||||
|
application/short_version="1.0"
|
||||||
|
application/version="1.0.0"
|
||||||
|
application/icon="res://icon.svg"
|
||||||
|
application/bundle_identifier="com.whaletown.game"
|
||||||
|
application/signature=""
|
||||||
|
application/app_category="Games"
|
||||||
|
display/high_res=true
|
||||||
|
codesign/codesign=1
|
||||||
|
codesign/installer_identity=""
|
||||||
|
codesign/apple_team_id=""
|
||||||
|
notarization/notarization=0
|
||||||
|
|
||||||
|
[preset.8]
|
||||||
|
|
||||||
|
name="WhaleTown Windows"
|
||||||
|
platform="Windows Desktop"
|
||||||
|
runnable=false
|
||||||
|
advanced_options=false
|
||||||
|
dedicated_server=false
|
||||||
|
custom_features=""
|
||||||
|
export_filter="all_resources"
|
||||||
|
include_filter=""
|
||||||
|
exclude_filter="build/**,output/**,docs/**,scripts/**,tools/**,assets/characters/reference/**,assets/characters/skin_generation_references/**,assets/ui/chat/**,assets/ui/auth/registration_choice/generated/**,assets/ui/auth/registration_choice/preview/**,assets/ui/auth/registration_choice/redesign_preview/**,assets/ui/auth/registration_choice/redesign/*_magenta.png*,assets/maps/square/v1/square_base_terrain_v*.png*,assets/maps/work_zone/v1/**,assets/maps/work_zone/v2/**,assets/maps/work_zone/v3/**,assets/maps/work_zone/v4/**,assets/maps/work_zone/v5/**,assets/maps/work_zone/v6/**,assets/maps/work_zone/v7/**,assets/maps/work_zone/v8/**,assets/maps/work_zone/v9/**,assets/maps/work_zone/v10/**,assets/maps/work_zone/v11/**,assets/maps/work_zone/v12/**,assets/maps/work_zone/v16/**,assets/maps/work_zone/v17/**,assets/maps/work_zone/v18/**,assets/maps/work_zone/v19/**,assets/maps/work_zone/v20/**,assets/maps/work_zone/v21/**,assets/maps/work_zone/v22/**,assets/maps/work_zone/v23/**,assets/maps/work_zone/v24/**,assets/maps/work_zone/v25/**"
|
||||||
|
export_path="build/desktop/windows/WhaleTown.exe"
|
||||||
|
patches=PackedStringArray()
|
||||||
|
encryption_include_filters=""
|
||||||
|
encryption_exclude_filters=""
|
||||||
|
seed=0
|
||||||
|
encrypt_pck=false
|
||||||
|
encrypt_directory=false
|
||||||
|
script_export_mode=2
|
||||||
|
|
||||||
|
[preset.8.options]
|
||||||
|
|
||||||
|
binary_format/architecture="x86_64"
|
||||||
|
custom_template/debug=""
|
||||||
|
custom_template/release=""
|
||||||
|
debug/export_console_wrapper=1
|
||||||
|
binary_format/embed_pck=true
|
||||||
|
texture_format/bptc=true
|
||||||
|
texture_format/s3tc=true
|
||||||
|
texture_format/etc2=false
|
||||||
|
application/modify_resources=true
|
||||||
|
application/icon="res://icon.svg"
|
||||||
|
application/file_version="1.0.0.0"
|
||||||
|
application/product_version="1.0.0"
|
||||||
|
application/product_name="WhaleTown"
|
||||||
|
|||||||
@@ -14,13 +14,15 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
|
|||||||
|
|
||||||
[application]
|
[application]
|
||||||
|
|
||||||
config/name="WhaleTown V2"
|
config/name="WhaleTown"
|
||||||
run/main_scene="res://scenes/ui/AuthScene.tscn"
|
config/version="1.0.0"
|
||||||
|
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"
|
||||||
|
|
||||||
[autoload]
|
[autoload]
|
||||||
|
|
||||||
|
UIFontManager="*res://_Core/managers/UIFontManager.gd"
|
||||||
SceneManager="*res://_Core/managers/SceneManager.gd"
|
SceneManager="*res://_Core/managers/SceneManager.gd"
|
||||||
EventSystem="*res://_Core/systems/EventSystem.gd"
|
EventSystem="*res://_Core/systems/EventSystem.gd"
|
||||||
ApiClient="*res://_Core/managers/ApiClient.gd"
|
ApiClient="*res://_Core/managers/ApiClient.gd"
|
||||||
@@ -32,6 +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]
|
||||||
|
|
||||||
@@ -40,6 +43,10 @@ window/size/viewport_height=1440
|
|||||||
window/stretch/mode="canvas_items"
|
window/stretch/mode="canvas_items"
|
||||||
window/stretch/aspect="expand"
|
window/stretch/aspect="expand"
|
||||||
|
|
||||||
|
[gui]
|
||||||
|
|
||||||
|
theme/custom_font="res://assets/fonts/msyh-web.ttf"
|
||||||
|
|
||||||
[input]
|
[input]
|
||||||
|
|
||||||
move_left={
|
move_left={
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[gd_scene format=3 uid="uid://c2flhkqk5icab"]
|
[gd_scene format=3 uid="uid://c2flhkqk5icab"]
|
||||||
|
|
||||||
[ext_resource type="Script" uid="uid://ccesagjisqodl" path="res://scenes/Maps/CafeInterior.gd" id="1_script"]
|
[ext_resource type="Script" uid="uid://ccesagjisqodl" path="res://scenes/Maps/CafeInterior.gd" id="1_script"]
|
||||||
[ext_resource type="Texture2D" path="res://assets/maps/cafe/cafe_service_hall_base_v1.png" id="2_map"]
|
[ext_resource type="Texture2D" path="res://assets/maps/cafe/whale_cafe_large_service_hall_base_v4.png" id="2_map"]
|
||||||
[ext_resource type="PackedScene" path="res://scenes/characters/player.tscn" id="3_player"]
|
[ext_resource type="PackedScene" path="res://scenes/characters/player.tscn" id="3_player"]
|
||||||
[ext_resource type="PackedScene" path="res://scenes/ui/ChatUI.tscn" id="4_chatui"]
|
[ext_resource type="PackedScene" path="res://scenes/ui/ChatUI.tscn" id="4_chatui"]
|
||||||
[ext_resource type="PackedScene" path="res://scenes/ui/PlayerHud.tscn" id="5_playerhud"]
|
[ext_resource type="PackedScene" path="res://scenes/ui/PlayerHud.tscn" id="5_playerhud"]
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
510
scenes/characters/NetworkNpc.gd
Normal 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
|
||||||
1
scenes/characters/NetworkNpc.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bfkuofgw3ftnp
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
18
scenes/characters/network_npc.tscn
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# 内部方法 - 样式处理
|
# 内部方法 - 样式处理
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -24,14 +24,16 @@ const REGISTRATION_CHOICE_UPLOAD_FILE_BOX_PATH: String = REGISTRATION_CHOICE_ASS
|
|||||||
const REGISTRATION_CHOICE_SPRITESHEET_GRID_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/spritesheet_grid_box.png"
|
const REGISTRATION_CHOICE_SPRITESHEET_GRID_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/spritesheet_grid_box.png"
|
||||||
const REGISTRATION_CHOICE_REFERENCE_UPLOAD_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/reference_upload_box.png"
|
const REGISTRATION_CHOICE_REFERENCE_UPLOAD_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/reference_upload_box.png"
|
||||||
const REGISTRATION_CHOICE_IMAGE_PLACEHOLDER_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/image_placeholder_box.png"
|
const REGISTRATION_CHOICE_IMAGE_PLACEHOLDER_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/image_placeholder_box.png"
|
||||||
const UI_FONT_PATH: String = "res://assets/fonts/msyh.ttc"
|
const UI_FONT_PATH: String = "res://assets/fonts/msyh-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 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,*.jpg,*.jpeg,*.webp"]
|
const SKIN_NATIVE_FILE_FILTERS: Array[String] = ["*.png"]
|
||||||
|
const WEB_FILE_MAX_BYTES: int = 8 * 1024 * 1024
|
||||||
const TEXT_COLOR: Color = Color(0.03, 0.12, 0.23, 1.0)
|
const TEXT_COLOR: Color = Color(0.03, 0.12, 0.23, 1.0)
|
||||||
const MUTED_COLOR: Color = Color(0.26, 0.34, 0.44, 1.0)
|
const MUTED_COLOR: Color = Color(0.26, 0.34, 0.44, 1.0)
|
||||||
const ACCENT_COLOR: Color = Color(0.10, 0.38, 0.68, 1.0)
|
const ACCENT_COLOR: Color = Color(0.10, 0.38, 0.68, 1.0)
|
||||||
@@ -44,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
|
||||||
@@ -53,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
|
||||||
@@ -91,11 +95,9 @@ var _scene_manager: Node
|
|||||||
var _appearance_manager: Node
|
var _appearance_manager: Node
|
||||||
var _ui_font: FontFile
|
var _ui_font: FontFile
|
||||||
var _brand_font: SystemFont
|
var _brand_font: SystemFont
|
||||||
var _choice_font: SystemFont
|
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
|
||||||
@@ -108,19 +110,25 @@ 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
|
||||||
var _pending_profile_sync_after_auth: bool = false
|
var _pending_profile_sync_after_auth: bool = false
|
||||||
var _deferred_enter_square_after_profile_sync: bool = false
|
var _deferred_enter_square_after_profile_sync: bool = false
|
||||||
var _is_sending_register_code: bool = false
|
var _is_sending_register_code: bool = false
|
||||||
|
var _web_file_picker: RefCounted = WebFilePicker.new()
|
||||||
|
|
||||||
func _enter_tree() -> void:
|
func _enter_tree() -> void:
|
||||||
_ui_font = load(UI_FONT_PATH) as FontFile
|
_ui_font = load(UI_FONT_PATH) as FontFile
|
||||||
_brand_font = SystemFont.new()
|
_brand_font = SystemFont.new()
|
||||||
_brand_font.font_names = PackedStringArray(["Arial Rounded MT Bold", "Avenir Next", "Trebuchet MS", "Arial"])
|
_brand_font.font_names = PackedStringArray(["Arial Rounded MT Bold", "Avenir Next", "Trebuchet MS", "Arial"])
|
||||||
_choice_font = SystemFont.new()
|
if OS.get_name() == "Web":
|
||||||
_choice_font.font_names = PackedStringArray(["STHeiti Medium", "Hiragino Sans GB", "PingFang SC", "Microsoft YaHei"])
|
_choice_font = _ui_font
|
||||||
|
else:
|
||||||
|
var systemFont := SystemFont.new()
|
||||||
|
systemFont.font_names = PackedStringArray(["STHeiti Medium", "Hiragino Sans GB", "PingFang SC", "Microsoft YaHei"])
|
||||||
|
_choice_font = systemFont
|
||||||
_build_v1_auth_ui()
|
_build_v1_auth_ui()
|
||||||
|
|
||||||
func _build_v1_auth_ui() -> void:
|
func _build_v1_auth_ui() -> void:
|
||||||
@@ -203,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())
|
||||||
@@ -240,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"
|
||||||
@@ -261,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)
|
||||||
@@ -274,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"
|
||||||
@@ -305,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()
|
||||||
@@ -685,18 +702,27 @@ 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()
|
||||||
|
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()
|
||||||
|
elif _auth_manager != null and _auth_manager.has_method("consume_browser_bootstrap_kind"):
|
||||||
|
var bootstrapKind := str(_auth_manager.call("consume_browser_bootstrap_kind"))
|
||||||
|
if not bootstrapKind.is_empty():
|
||||||
|
call_deferred("_complete_browser_bootstrap", bootstrapKind)
|
||||||
|
else:
|
||||||
|
login_identifier_input.grab_focus()
|
||||||
else:
|
else:
|
||||||
login_identifier_input.grab_focus()
|
login_identifier_input.grab_focus()
|
||||||
|
|
||||||
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)
|
||||||
@@ -725,19 +751,23 @@ func _connect_signals() -> void:
|
|||||||
_auth_manager.connect("profile_update_succeeded", _on_profile_update_succeeded)
|
_auth_manager.connect("profile_update_succeeded", _on_profile_update_succeeded)
|
||||||
_auth_manager.connect("profile_update_failed", _on_profile_update_failed)
|
_auth_manager.connect("profile_update_failed", _on_profile_update_failed)
|
||||||
_auth_manager.connect("auth_state_changed", _on_auth_state_changed)
|
_auth_manager.connect("auth_state_changed", _on_auth_state_changed)
|
||||||
|
if _auth_manager.has_signal("browser_bootstrap_received"):
|
||||||
|
_auth_manager.connect("browser_bootstrap_received", _complete_browser_bootstrap)
|
||||||
|
|
||||||
func _setup_skin_generation_requests() -> void:
|
func _notify_web_shell_ready() -> void:
|
||||||
_skin_generation_create_request = HTTPRequest.new()
|
if OS.get_name() != "Web" or not Engine.has_singleton("JavaScriptBridge"):
|
||||||
_skin_generation_create_request.name = "SkinGenerationCreateRequest"
|
return
|
||||||
_skin_generation_create_request.timeout = SKIN_GENERATION_REQUEST_TIMEOUT
|
var bridge: Object = Engine.get_singleton("JavaScriptBridge")
|
||||||
_skin_generation_create_request.request_completed.connect(_on_skin_generation_create_completed)
|
bridge.eval("window.whaletownGodotReady && window.whaletownGodotReady()", true)
|
||||||
add_child(_skin_generation_create_request)
|
|
||||||
|
|
||||||
_skin_generation_poll_request = HTTPRequest.new()
|
func _complete_browser_bootstrap(kind: String) -> void:
|
||||||
_skin_generation_poll_request.name = "SkinGenerationPollRequest"
|
if _is_submitting:
|
||||||
_skin_generation_poll_request.timeout = SKIN_GENERATION_REQUEST_TIMEOUT
|
return
|
||||||
_skin_generation_poll_request.request_completed.connect(_on_skin_generation_poll_completed)
|
_set_submitting(true, "正在准备小镇...")
|
||||||
add_child(_skin_generation_poll_request)
|
if kind == "register":
|
||||||
|
_on_register_succeeded(_auth_manager.call("get_current_user"))
|
||||||
|
return
|
||||||
|
_on_login_succeeded(_auth_manager.call("get_current_user"))
|
||||||
|
|
||||||
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():
|
||||||
@@ -748,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 = ""
|
||||||
|
|
||||||
@@ -905,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 := [
|
||||||
@@ -937,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"):
|
||||||
@@ -945,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:
|
||||||
@@ -975,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:
|
||||||
@@ -1071,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)
|
||||||
|
|
||||||
@@ -1139,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
|
||||||
@@ -1171,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()
|
||||||
@@ -1187,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:
|
||||||
@@ -1401,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("该账号已完成注册角色生成,正在进入小镇...")
|
||||||
@@ -1426,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
|
||||||
@@ -1442,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():
|
||||||
@@ -1547,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
|
||||||
@@ -1626,6 +1606,9 @@ func _hide_skin_workshop() -> void:
|
|||||||
skin_workshop_overlay.hide()
|
skin_workshop_overlay.hide()
|
||||||
|
|
||||||
func _on_workshop_source_pressed() -> void:
|
func _on_workshop_source_pressed() -> void:
|
||||||
|
if _open_web_file_picker("workshop"):
|
||||||
|
_set_workshop_generation_status("请选择角色参考图片")
|
||||||
|
return
|
||||||
var err := DisplayServer.file_dialog_show(
|
var err := DisplayServer.file_dialog_show(
|
||||||
"选择角色参考图片",
|
"选择角色参考图片",
|
||||||
_default_picker_dir(),
|
_default_picker_dir(),
|
||||||
@@ -1680,6 +1663,9 @@ func _update_skin_library_buttons() -> void:
|
|||||||
_apply_library_arrow_style(skin_library_next_button, true)
|
_apply_library_arrow_style(skin_library_next_button, true)
|
||||||
|
|
||||||
func _on_upload_avatar_pressed() -> void:
|
func _on_upload_avatar_pressed() -> void:
|
||||||
|
if _open_web_file_picker("avatar"):
|
||||||
|
status_label.text = "请选择头像图片"
|
||||||
|
return
|
||||||
var err := DisplayServer.file_dialog_show(
|
var err := DisplayServer.file_dialog_show(
|
||||||
"选择头像图片",
|
"选择头像图片",
|
||||||
_default_picker_dir(),
|
_default_picker_dir(),
|
||||||
@@ -1698,6 +1684,11 @@ func _on_upload_avatar_pressed() -> void:
|
|||||||
status_label.text = "系统文件选择器未打开:%s / %s" % [DisplayServer.get_name(), error_string(err)]
|
status_label.text = "系统文件选择器未打开:%s / %s" % [DisplayServer.get_name(), error_string(err)]
|
||||||
|
|
||||||
func _on_upload_skin_pressed() -> void:
|
func _on_upload_skin_pressed() -> void:
|
||||||
|
if _open_web_file_picker("skin"):
|
||||||
|
status_label.text = "请选择8x4角色皮肤PNG"
|
||||||
|
if is_instance_valid(workshop_status_label):
|
||||||
|
workshop_status_label.text = "等待选择8x4透明PNG"
|
||||||
|
return
|
||||||
var err := DisplayServer.file_dialog_show(
|
var err := DisplayServer.file_dialog_show(
|
||||||
"选择8x4角色皮肤PNG",
|
"选择8x4角色皮肤PNG",
|
||||||
_default_picker_dir(),
|
_default_picker_dir(),
|
||||||
@@ -1719,6 +1710,46 @@ func _on_upload_skin_pressed() -> void:
|
|||||||
return
|
return
|
||||||
status_label.text = "系统文件选择器未打开:%s / %s" % [DisplayServer.get_name(), error_string(err)]
|
status_label.text = "系统文件选择器未打开:%s / %s" % [DisplayServer.get_name(), error_string(err)]
|
||||||
|
|
||||||
|
func _open_web_file_picker(kind: String) -> bool:
|
||||||
|
if OS.get_name() != "Web" or _web_file_picker == null:
|
||||||
|
return false
|
||||||
|
var accept := ".png" if kind == "skin" else ".png,.jpg,.jpeg,.webp"
|
||||||
|
return bool(_web_file_picker.call("open", accept, WEB_FILE_MAX_BYTES, _on_web_file_selected.bind(kind)))
|
||||||
|
|
||||||
|
func _on_web_file_selected(status: String, fileName: String, _mimeType: String, base64Data: String, kind: String) -> void:
|
||||||
|
if status == "cancel":
|
||||||
|
if kind == "workshop":
|
||||||
|
_set_workshop_generation_status("已取消角色参考图选择")
|
||||||
|
else:
|
||||||
|
status_label.text = "已取消图片选择"
|
||||||
|
return
|
||||||
|
if status == "too_large":
|
||||||
|
var message := "图片不能超过8MB"
|
||||||
|
if kind == "workshop":
|
||||||
|
_set_workshop_generation_status(message)
|
||||||
|
else:
|
||||||
|
status_label.text = message
|
||||||
|
return
|
||||||
|
if status != "ok":
|
||||||
|
var message := "浏览器读取图片失败"
|
||||||
|
if kind == "workshop":
|
||||||
|
_set_workshop_generation_status(message)
|
||||||
|
else:
|
||||||
|
status_label.text = message
|
||||||
|
return
|
||||||
|
|
||||||
|
var path := WebFilePicker.save_base64_file(base64Data, fileName, kind)
|
||||||
|
if path.is_empty():
|
||||||
|
status_label.text = "图片暂存失败"
|
||||||
|
return
|
||||||
|
match kind:
|
||||||
|
"avatar":
|
||||||
|
_on_native_avatar_file_selected(true, PackedStringArray([path]), 0)
|
||||||
|
"skin":
|
||||||
|
_on_native_skin_file_selected(true, PackedStringArray([path]), 0)
|
||||||
|
"workshop":
|
||||||
|
_on_native_workshop_source_file_selected(true, PackedStringArray([path]), 0)
|
||||||
|
|
||||||
func _open_macos_file_dialog(title: String, callbackMethod: String) -> bool:
|
func _open_macos_file_dialog(title: String, callbackMethod: String) -> bool:
|
||||||
if OS.get_name() != "macOS":
|
if OS.get_name() != "macOS":
|
||||||
return false
|
return false
|
||||||
@@ -1871,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:
|
||||||
@@ -1894,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:
|
||||||
@@ -1905,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()
|
||||||
@@ -2110,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
|
||||||
@@ -2371,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
[gd_scene load_steps=15 format=3 uid="uid://bv7k2nan4xj8q"]
|
[gd_scene load_steps=16 format=3 uid="uid://bv7k2nan4xj8q"]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://scenes/ui/ChatUI.gd" id="1"]
|
[ext_resource type="Script" path="res://scenes/ui/ChatUI.gd" id="1"]
|
||||||
[ext_resource type="Script" path="res://scenes/ui/BubbleSendButton.gd" id="2"]
|
[ext_resource type="Script" path="res://scenes/ui/BubbleSendButton.gd" id="2"]
|
||||||
|
[ext_resource type="Theme" path="res://assets/ui/world_text_theme.tres" id="3"]
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_panel"]
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_panel"]
|
||||||
bg_color = Color(1, 0.996, 0.984, 0.97)
|
bg_color = Color(1, 0.996, 0.984, 0.97)
|
||||||
@@ -145,6 +146,7 @@ anchor_bottom = 1.0
|
|||||||
grow_horizontal = 2
|
grow_horizontal = 2
|
||||||
grow_vertical = 2
|
grow_vertical = 2
|
||||||
mouse_filter = 2
|
mouse_filter = 2
|
||||||
|
theme = ExtResource("3")
|
||||||
script = ExtResource("1")
|
script = ExtResource("1")
|
||||||
|
|
||||||
[node name="ChatPanel" type="PanelContainer" parent="."]
|
[node name="ChatPanel" type="PanelContainer" parent="."]
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const TEX_TAB_LABEL_PRODUCTIVE = preload("res://assets/ui/datawhale_honor/honor_
|
|||||||
const TEX_TAB_LABEL_SOCIAL = preload("res://assets/ui/datawhale_honor/honor_tab_label_social.png")
|
const TEX_TAB_LABEL_SOCIAL = preload("res://assets/ui/datawhale_honor/honor_tab_label_social.png")
|
||||||
const TEX_TAB_LABEL_RISING = preload("res://assets/ui/datawhale_honor/honor_tab_label_rising.png")
|
const TEX_TAB_LABEL_RISING = preload("res://assets/ui/datawhale_honor/honor_tab_label_rising.png")
|
||||||
const TEX_TAB_LABEL_COMPREHENSIVE = preload("res://assets/ui/datawhale_honor/honor_tab_label_comprehensive.png")
|
const TEX_TAB_LABEL_COMPREHENSIVE = preload("res://assets/ui/datawhale_honor/honor_tab_label_comprehensive.png")
|
||||||
const FONT_UI = preload("res://assets/fonts/msyh.ttc")
|
const FONT_UI = preload("res://assets/fonts/msyh-web.ttf")
|
||||||
const TEX_AVATAR_FALLBACK_1 = preload("res://assets/ui/auth/generated/auth_character_preview_front.png")
|
const TEX_AVATAR_FALLBACK_1 = preload("res://assets/ui/auth/generated/auth_character_preview_front.png")
|
||||||
const TEX_AVATAR_FALLBACK_2 = preload("res://assets/ui/auth/generated/auth_character_preview_left.png")
|
const TEX_AVATAR_FALLBACK_2 = preload("res://assets/ui/auth/generated/auth_character_preview_left.png")
|
||||||
const TEX_AVATAR_FALLBACK_3 = preload("res://assets/ui/auth/generated/auth_character_preview_right.png")
|
const TEX_AVATAR_FALLBACK_3 = preload("res://assets/ui/auth/generated/auth_character_preview_right.png")
|
||||||
@@ -1126,8 +1126,8 @@ func _podiumMetaLine(data: Dictionary) -> String:
|
|||||||
if location.is_empty():
|
if location.is_empty():
|
||||||
return domain
|
return domain
|
||||||
if domain.is_empty():
|
if domain.is_empty():
|
||||||
return "📍 %s" % location
|
return location
|
||||||
return "📍 %s · %s" % [location, domain]
|
return "%s · %s" % [location, domain]
|
||||||
|
|
||||||
func _podiumTags(data: Dictionary) -> Array[String]:
|
func _podiumTags(data: Dictionary) -> Array[String]:
|
||||||
var tags: Array[String] = []
|
var tags: Array[String] = []
|
||||||
@@ -1165,7 +1165,13 @@ func _avatarPrimaryTextureFromData(data: Dictionary) -> Texture2D:
|
|||||||
return null
|
return null
|
||||||
|
|
||||||
func _avatarUrlFromData(data: Dictionary) -> String:
|
func _avatarUrlFromData(data: Dictionary) -> String:
|
||||||
return _firstNonEmptyString(data, ["avatarUrl", "avatar_url", "avatar", "avatarURL"])
|
var avatarUrl := _firstNonEmptyString(data, ["avatarUrl", "avatar_url", "avatar", "avatarURL"])
|
||||||
|
if not avatarUrl.begins_with("/"):
|
||||||
|
return avatarUrl
|
||||||
|
var apiBaseUrl := NetworkConfig.get_api_base_url()
|
||||||
|
if avatarUrl.begins_with("/api/") and apiBaseUrl.ends_with("/api"):
|
||||||
|
return apiBaseUrl.trim_suffix("/api") + avatarUrl
|
||||||
|
return apiBaseUrl + avatarUrl
|
||||||
|
|
||||||
func _firstNonEmptyString(data: Dictionary, keys: Array[String]) -> String:
|
func _firstNonEmptyString(data: Dictionary, keys: Array[String]) -> String:
|
||||||
for key in keys:
|
for key in keys:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[gd_scene load_steps=2 format=3]
|
[gd_scene load_steps=3 format=3]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://scenes/ui/PlayerHud.gd" id="1"]
|
[ext_resource type="Script" path="res://scenes/ui/PlayerHud.gd" id="1"]
|
||||||
|
[ext_resource type="Theme" path="res://assets/ui/world_text_theme.tres" id="2_theme"]
|
||||||
|
|
||||||
[node name="PlayerHud" type="Control"]
|
[node name="PlayerHud" type="Control"]
|
||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
@@ -10,4 +11,5 @@ anchor_bottom = 1.0
|
|||||||
grow_horizontal = 2
|
grow_horizontal = 2
|
||||||
grow_vertical = 2
|
grow_vertical = 2
|
||||||
mouse_filter = 2
|
mouse_filter = 2
|
||||||
|
theme = ExtResource("2_theme")
|
||||||
script = ExtResource("1")
|
script = ExtResource("1")
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const ICON_SCRIPT: Script = preload("res://scenes/ui/SettingsPanelIcon.gd")
|
|||||||
const TOGGLE_SCRIPT: Script = preload("res://scenes/ui/SettingsToggle.gd")
|
const TOGGLE_SCRIPT: Script = preload("res://scenes/ui/SettingsToggle.gd")
|
||||||
const SLIDER_SCRIPT: Script = preload("res://scenes/ui/SettingsSlider.gd")
|
const SLIDER_SCRIPT: Script = preload("res://scenes/ui/SettingsSlider.gd")
|
||||||
const BADGE_TEXTURE_PATH: String = "res://assets/ui/settings/settings_whale_badge_asset_v1.png"
|
const BADGE_TEXTURE_PATH: String = "res://assets/ui/settings/settings_whale_badge_asset_v1.png"
|
||||||
|
const WebFilePicker = preload("res://_Core/utils/WebFilePicker.gd")
|
||||||
|
|
||||||
const TEXT_COLOR: Color = Color(0.188, 0.294, 0.424)
|
const TEXT_COLOR: Color = Color(0.188, 0.294, 0.424)
|
||||||
const MUTED_COLOR: Color = Color(0.560, 0.639, 0.733)
|
const MUTED_COLOR: Color = Color(0.560, 0.639, 0.733)
|
||||||
@@ -22,6 +23,7 @@ const SOFT_BLUE: Color = Color(0.918, 0.961, 0.992, 0.92)
|
|||||||
const DANGER_COLOR: Color = Color(0.894, 0.392, 0.357)
|
const DANGER_COLOR: Color = Color(0.894, 0.392, 0.357)
|
||||||
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 AVATAR_NATIVE_FILE_FILTERS: Array[String] = ["*.png,*.jpg,*.jpeg,*.webp"]
|
const AVATAR_NATIVE_FILE_FILTERS: Array[String] = ["*.png,*.jpg,*.jpeg,*.webp"]
|
||||||
|
const WEB_FILE_MAX_BYTES: int = 8 * 1024 * 1024
|
||||||
|
|
||||||
const CATEGORIES: Array[Dictionary] = [
|
const CATEGORIES: Array[Dictionary] = [
|
||||||
{"id": "basic", "label": "基础", "icon": "basic"},
|
{"id": "basic", "label": "基础", "icon": "basic"},
|
||||||
@@ -71,6 +73,7 @@ var _savedSettings: Dictionary = DEFAULT_SETTINGS.duplicate(true)
|
|||||||
var _isOpen: bool = false
|
var _isOpen: bool = false
|
||||||
var _transitionTween: Tween
|
var _transitionTween: Tween
|
||||||
var _avatarUploadPending: bool = false
|
var _avatarUploadPending: bool = false
|
||||||
|
var _webFilePicker: RefCounted = WebFilePicker.new()
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
visible = false
|
visible = false
|
||||||
@@ -976,6 +979,9 @@ func _on_reconnect_pressed() -> void:
|
|||||||
_statusLabel.text = "已请求重新连接聊天服务"
|
_statusLabel.text = "已请求重新连接聊天服务"
|
||||||
|
|
||||||
func _on_upload_avatar_pressed() -> void:
|
func _on_upload_avatar_pressed() -> void:
|
||||||
|
if _try_open_web_avatar_file_picker():
|
||||||
|
_set_account_status("请选择头像图片")
|
||||||
|
return
|
||||||
if _open_macos_avatar_file_dialog():
|
if _open_macos_avatar_file_dialog():
|
||||||
return
|
return
|
||||||
if _try_open_native_avatar_file_picker():
|
if _try_open_native_avatar_file_picker():
|
||||||
@@ -983,6 +989,27 @@ func _on_upload_avatar_pressed() -> void:
|
|||||||
return
|
return
|
||||||
_set_account_status("系统文件选择器暂时不可用")
|
_set_account_status("系统文件选择器暂时不可用")
|
||||||
|
|
||||||
|
func _try_open_web_avatar_file_picker() -> bool:
|
||||||
|
if OS.get_name() != "Web" or _webFilePicker == null:
|
||||||
|
return false
|
||||||
|
return bool(_webFilePicker.call("open", ".png,.jpg,.jpeg,.webp", WEB_FILE_MAX_BYTES, _on_web_avatar_file_selected))
|
||||||
|
|
||||||
|
func _on_web_avatar_file_selected(status: String, fileName: String, _mimeType: String, base64Data: String) -> void:
|
||||||
|
if status == "cancel":
|
||||||
|
_set_account_status("已取消头像选择")
|
||||||
|
return
|
||||||
|
if status == "too_large":
|
||||||
|
_set_account_status("头像图片不能超过8MB")
|
||||||
|
return
|
||||||
|
if status != "ok":
|
||||||
|
_set_account_status("浏览器读取头像失败")
|
||||||
|
return
|
||||||
|
var path := WebFilePicker.save_base64_file(base64Data, fileName, "avatar")
|
||||||
|
if path.is_empty():
|
||||||
|
_set_account_status("头像图片暂存失败")
|
||||||
|
return
|
||||||
|
_on_avatar_file_selected(path)
|
||||||
|
|
||||||
func _try_open_native_avatar_file_picker() -> bool:
|
func _try_open_native_avatar_file_picker() -> bool:
|
||||||
if DisplayServer.get_name() == "headless":
|
if DisplayServer.get_name() == "headless":
|
||||||
return false
|
return false
|
||||||
|
|||||||
9
scenes/ui/WebBootstrap.gd
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
extends Control
|
||||||
|
|
||||||
|
var _scene_manager: Node
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_scene_manager = get_node_or_null("/root/SceneManager")
|
||||||
|
if _scene_manager == null:
|
||||||
|
return
|
||||||
|
_scene_manager.call_deferred("change_scene", "auth", false)
|
||||||
1
scenes/ui/WebBootstrap.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://22fxlhvp012i
|
||||||
12
scenes/ui/WebBootstrap.tscn
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scenes/ui/WebBootstrap.gd" id="1_bootstrap"]
|
||||||
|
|
||||||
|
[node name="WebBootstrap" type="Control"]
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
script = ExtResource("1_bootstrap")
|
||||||
532
scenes/ui/WorldBulletinPanel.gd
Normal 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
|
||||||
1
scenes/ui/WorldBulletinPanel.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://d1dka1huar71o
|
||||||
13
scenes/ui/WorldBulletinPanel.tscn
Normal 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
@@ -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"
|
||||||
59
scripts/build_progressive_web.sh
Executable file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
GODOT_BIN=${GODOT_BIN:-/Applications/Godot.app/Contents/MacOS/Godot}
|
||||||
|
WEB_DIR="$PROJECT_DIR/build/web"
|
||||||
|
PACK_DIR="$WEB_DIR/packs"
|
||||||
|
|
||||||
|
if [ ! -x "$GODOT_BIN" ]; then
|
||||||
|
echo "Godot executable not found: $GODOT_BIN" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$WEB_DIR" "$PACK_DIR"
|
||||||
|
find "$PACK_DIR" -maxdepth 1 -type f \( -name '*.pck' -o -name 'manifest.json' \) -delete
|
||||||
|
find "$WEB_DIR" -maxdepth 1 -type f -name 'index-*.pck' -delete
|
||||||
|
|
||||||
|
"$GODOT_BIN" --headless --path "$PROJECT_DIR" --export-release "Web" "$WEB_DIR/index.html"
|
||||||
|
|
||||||
|
core_pack_path="$WEB_DIR/index.pck"
|
||||||
|
core_hash=$(shasum -a 256 "$core_pack_path" | awk '{print substr($1, 1, 12)}')
|
||||||
|
core_pack_name="index-$core_hash.pck"
|
||||||
|
core_pack_size=$(stat -f '%z' "$core_pack_path")
|
||||||
|
mv "$core_pack_path" "$WEB_DIR/$core_pack_name"
|
||||||
|
perl -0pi -e "s/__WHALETOWN_CORE_PACK__/$core_pack_name/g; s/__WHALETOWN_CORE_PACK_SIZE__/$core_pack_size/g" "$WEB_DIR/index.html"
|
||||||
|
|
||||||
|
build_pack() {
|
||||||
|
pack_key=$1
|
||||||
|
preset=$2
|
||||||
|
temp_path="$PACK_DIR/$pack_key.pck"
|
||||||
|
"$GODOT_BIN" --headless --path "$PROJECT_DIR" --export-pack "$preset" "$temp_path" >&2
|
||||||
|
hash=$(shasum -a 256 "$temp_path" | awk '{print substr($1, 1, 12)}')
|
||||||
|
file_name="$pack_key-$hash.pck"
|
||||||
|
mv "$temp_path" "$PACK_DIR/$file_name"
|
||||||
|
size=$(stat -f '%z' "$PACK_DIR/$file_name")
|
||||||
|
jq -n --arg file "$file_name" --argjson size "$size" '{file: $file, size: $size}'
|
||||||
|
}
|
||||||
|
|
||||||
|
square=$(build_pack "square" "Scene Square")
|
||||||
|
work_zone=$(build_pack "work_zone" "Scene Work Zone")
|
||||||
|
cafe=$(build_pack "cafe_interior" "Scene Cafe")
|
||||||
|
personal_space=$(build_pack "personal_space" "Scene Personal Space")
|
||||||
|
auth=$(build_pack "auth" "Scene Auth")
|
||||||
|
auth_pack_size=$(printf '%s' "$auth" | jq -r '.size')
|
||||||
|
perl -0pi -e "s/__WHALETOWN_AUTH_PACK_SIZE__/$auth_pack_size/g" "$WEB_DIR/index.html"
|
||||||
|
|
||||||
|
jq -n \
|
||||||
|
--argjson square "$square" \
|
||||||
|
--argjson work_zone "$work_zone" \
|
||||||
|
--argjson cafe_interior "$cafe" \
|
||||||
|
--argjson personal_space "$personal_space" \
|
||||||
|
--argjson auth "$auth" \
|
||||||
|
'{version: 1, packs: {auth: $auth, square: $square, work_zone: $work_zone, cafe_interior: $cafe_interior, personal_space: $personal_space}}' \
|
||||||
|
> "$PACK_DIR/manifest.json"
|
||||||
|
|
||||||
|
cp "$PROJECT_DIR/web/auth-background.jpg" "$WEB_DIR/auth-background.jpg"
|
||||||
|
|
||||||
|
echo "Progressive web build created in $WEB_DIR"
|
||||||
|
du -h "$WEB_DIR/$core_pack_name" "$WEB_DIR/index.wasm" "$WEB_DIR/auth-background.jpg" "$PACK_DIR"/*.pck
|
||||||
46
scripts/prepare_site_release.sh
Executable 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"
|
||||||
48
scripts/test_network_npc_animations.gd
Normal 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()
|
||||||
1
scripts/test_network_npc_animations.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cadhx6wgvrk3y
|
||||||