12 Commits
dev ... main

Author SHA1 Message Date
fc6c3c1bd3 feat: expand multiplayer, chat, and release support
- add network NPC synchronization and dialogue interactions
- add world bulletin publishing and display
- improve authentication, session refresh, and appearance sync
- synchronize player direction and movement animations
- improve input focus and progressive Web loading
- add macOS/Windows builds and deployment configuration
- include required fonts, shaders, and runtime assets
2026-09-08 21:37:43 +08:00
175621f66c fix: remove unsupported ranking emoji 2026-08-02 01:46:56 +08:00
366f967622 fix: display uploaded and ranking avatars 2026-08-02 01:00:02 +08:00
6fb135ba71 perf: reduce web font and scene pack sizes 2026-08-01 23:45:03 +08:00
38df2541ae fix: enforce Chinese font for all UI controls 2026-08-01 23:00:38 +08:00
32bcaef4fa fix: restore original cafe interior backdrop 2026-08-01 22:49:39 +08:00
28c5e5ccfb fix: configure Chinese font across all UI 2026-08-01 22:42:54 +08:00
a274f6475f fix: apply Chinese font theme to chat UI 2026-08-01 22:30:12 +08:00
52c5a76a01 fix: restore full Godot auth in progressive web build 2026-08-01 21:50:22 +08:00
7a07f52e2c fix: stabilize web registration and asset loading 2026-08-01 20:18:47 +08:00
bcdaa737eb build: slim web export resources 2026-08-01 17:29:55 +08:00
593e1e4ea1 Merge pull request 'fix: prepare frontend production exports' (#1) from ANGJustinl/whale-town-front-v2:main into main 2026-08-01 02:32:18 +08:00
107 changed files with 5595 additions and 489 deletions

3
.gitignore vendored
View File

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

View File

@@ -8,16 +8,16 @@
"active_profile": "production",
"profiles": {
"production": {
"api_base_url": "https://whaletownend.xinghangee.icu",
"chat_ws_url": "wss://whaletownend.xinghangee.icu/game",
"location_ws_url": "wss://whaletownend.xinghangee.icu/game",
"game_ws_url": "wss://whaletownend.xinghangee.icu/game"
"api_base_url": "https://whaletown.novamailio.com/api",
"chat_ws_url": "wss://whaletown.novamailio.com/game",
"location_ws_url": "wss://whaletown.novamailio.com/game",
"game_ws_url": "wss://whaletown.novamailio.com/game"
}
},
"api_base_url": "https://whaletownend.xinghangee.icu",
"chat_ws_url": "wss://whaletownend.xinghangee.icu/game",
"location_ws_url": "wss://whaletownend.xinghangee.icu/game",
"game_ws_url": "wss://whaletownend.xinghangee.icu/game",
"api_base_url": "https://whaletown.novamailio.com/api",
"chat_ws_url": "wss://whaletown.novamailio.com/game",
"location_ws_url": "wss://whaletown.novamailio.com/game",
"game_ws_url": "wss://whaletown.novamailio.com/game",
"timeout": 30,
"retry_count": 3
},

37
DESKTOP_BUILD.md Normal file
View File

@@ -0,0 +1,37 @@
# WhaleTown 桌面客户端构建
桌面客户端和 Web 客户端使用同一套 Godot 项目、正式登录接口和 WebSocket 服务。用户下载客户端后直接进入登录页,账号数据仍由线上服务保存,不写入项目目录。
## 构建目标
- macOS`build/desktop/macos/WhaleTown.app`,通用架构,支持 Apple Silicon 和 Intel Mac。
- Windows`build/desktop/windows/WhaleTown.exe`64 位桌面版本。
## 环境要求
1. 安装与项目一致的 Godot `4.6.2.stable`
2. 在 Godot 编辑器的导出模板管理器中安装 macOS 和 Windows Desktop 模板。
3. 默认网络配置位于 `Config/game_config.json`,发布前确认 `production` 地址指向正式服务。
## 构建命令
`whale-town-front-v2` 目录执行:
```bash
./scripts/build_desktop.sh macos
./scripts/build_desktop.sh windows
./scripts/build_desktop.sh all
./scripts/build_desktop.sh release
```
`release` 会同时构建两个平台,生成 ZIP并在 `build/desktop/release/SHA256SUMS.txt` 写入校验值。也可以通过 `GODOT_BIN=/path/to/Godot``WHALETOWN_VERSION=1.0.0` 指定 Godot 路径及发布版本。
Windows 构建可以从 macOS 交叉导出,但最终发布前仍应在真实 Windows 机器上启动一次,确认显卡、字体、文件选择器和网络连接正常。
## 面向用户发布
首版可以直接将 `build/desktop/release` 中的 ZIP 供内测下载。正式公开下载前还需要:
- macOS 使用 Apple Developer ID 签名和 notarization避免 Gatekeeper 拦截。
- Windows 使用代码签名证书,降低 SmartScreen 警告。
- 将客户端压缩包上传到下载站,并提供版本号、更新日期和 SHA-256 校验值。

View File

@@ -19,6 +19,12 @@ const REMOTE_PLAYER_JOINED = "remote_player_joined"
const REMOTE_PLAYER_LEFT = "remote_player_left"
const REMOTE_PLAYER_POSITION_UPDATED = "remote_player_position_updated"
const REMOTE_PLAYERS_SNAPSHOT_READY = "remote_players_snapshot_ready"
const NPC_SNAPSHOT_READY = "npc_snapshot_ready"
const NPC_ACTION_STARTED = "npc_action_started"
const NPC_ACTION_COMPLETED = "npc_action_completed"
const NPC_SPOKE = "npc_spoke"
const NPC_INTERACTION_ERROR = "npc_interaction_error"
const NPC_CONVERSATION = "npc_conversation"
const REMOTE_SKIN_READY = "remote_skin_ready"
const REMOTE_SKIN_FAILED = "remote_skin_failed"
const PLAYER_HEALTH_CHANGED = "player_health_changed"

View File

@@ -13,6 +13,16 @@ const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const REQUEST_TIMEOUT: float = 12.0
var _activeRequests: Array[HTTPRequest] = []
var _pendingAuthRetries: Array[Dictionary] = []
var _awaitingSessionRefresh: bool = false
func _ready() -> void:
call_deferred("_connect_auth_manager")
func _connect_auth_manager() -> void:
var authManager := get_node_or_null("/root/AuthManager")
if authManager != null and authManager.has_signal("session_refresh_completed") and not authManager.is_connected("session_refresh_completed", _on_session_refresh_completed):
authManager.connect("session_refresh_completed", _on_session_refresh_completed)
func _exit_tree() -> void:
for request in _activeRequests:
@@ -20,6 +30,7 @@ func _exit_tree() -> void:
request.cancel_request()
request.queue_free()
_activeRequests.clear()
_pendingAuthRetries.clear()
func get_json(endpoint: String, callback: Callable, authenticated: bool = true) -> void:
request_json(endpoint, {}, callback, HTTPClient.METHOD_GET, authenticated)
@@ -34,6 +45,9 @@ func put_json(endpoint: String, payload: Dictionary, callback: Callable, authent
request_json(endpoint, payload, callback, HTTPClient.METHOD_PUT, authenticated)
func request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_GET, authenticated: bool = true) -> void:
_request_json(endpoint, payload, callback, method, authenticated, 0)
func _request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int, authenticated: bool, authRetryCount: int) -> void:
var request := HTTPRequest.new()
request.timeout = REQUEST_TIMEOUT
add_child(request)
@@ -42,7 +56,7 @@ func request_json(endpoint: String, payload: Dictionary, callback: Callable, met
request.request_completed.connect(func(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
_activeRequests.erase(request)
request.queue_free()
_handle_response(endpoint, result, responseCode, body, callback)
_handle_response(endpoint, result, responseCode, body, callback, payload, method, authenticated, authRetryCount)
)
var url := "%s%s" % [NetworkConfig.get_api_base_url(), endpoint]
@@ -64,13 +78,27 @@ func _headers(authenticated: bool) -> PackedStringArray:
headers.append("Authorization: Bearer %s" % accessToken)
return headers
func _handle_response(endpoint: String, result: int, responseCode: int, body: PackedByteArray, callback: Callable) -> void:
func _handle_response(
endpoint: String,
result: int,
responseCode: int,
body: PackedByteArray,
callback: Callable,
payload: Dictionary,
method: int,
authenticated: bool,
authRetryCount: int
) -> void:
if result != HTTPRequest.RESULT_SUCCESS:
var message := "网络请求失败: %s" % _http_result_to_string(result)
request_failed.emit(endpoint, message)
callback.call(false, {}, {"message": message})
return
if responseCode == 401 and authenticated and authRetryCount < 1:
_queue_authenticated_retry(endpoint, payload, callback, method, authRetryCount + 1)
return
var bodyText := body.get_string_from_utf8()
var json := JSON.new()
if json.parse(bodyText) != OK:
@@ -100,6 +128,63 @@ func _handle_response(endpoint: String, result: int, responseCode: int, body: Pa
request_failed.emit(endpoint, str(errorInfo.get("message", "请求失败")))
callback.call(false, response, errorInfo)
func _queue_authenticated_retry(endpoint: String, payload: Dictionary, callback: Callable, method: int, authRetryCount: int) -> void:
_pendingAuthRetries.append({
"endpoint": endpoint,
"payload": payload.duplicate(true),
"callback": callback,
"method": method,
"auth_retry_count": authRetryCount,
"account_generation": _current_account_generation(),
})
if _awaitingSessionRefresh:
return
var authManager := get_node_or_null("/root/AuthManager")
if authManager == null or not authManager.has_method("refresh_session"):
_finish_authenticated_retries(false)
return
_awaitingSessionRefresh = true
if not bool(authManager.call("refresh_session")):
_finish_authenticated_retries(false)
func _on_session_refresh_completed(success: bool) -> void:
_finish_authenticated_retries(success)
func _finish_authenticated_retries(success: bool) -> void:
var retries := _pendingAuthRetries.duplicate()
_pendingAuthRetries.clear()
_awaitingSessionRefresh = false
var currentGeneration := _current_account_generation()
for retryVariant in retries:
if not (retryVariant is Dictionary):
continue
var retry: Dictionary = retryVariant
var callback: Callable = retry.get("callback", Callable())
if not callback.is_valid():
continue
if not success or int(retry.get("account_generation", -1)) != currentGeneration:
callback.call(false, {}, {
"message": "登录状态已过期,请重新登录",
"response_code": 401,
"error_code": "SESSION_EXPIRED",
})
continue
_request_json(
str(retry.get("endpoint", "")),
retry.get("payload", {}) as Dictionary,
callback,
int(retry.get("method", HTTPClient.METHOD_GET)),
true,
int(retry.get("auth_retry_count", 1))
)
func _current_account_generation() -> int:
var authManager := get_node_or_null("/root/AuthManager")
if authManager == null or not authManager.has_method("get_account_generation"):
return -1
return int(authManager.call("get_account_generation"))
func _http_result_to_string(result: int) -> String:
match result:
HTTPRequest.RESULT_CHUNKED_BODY_SIZE_MISMATCH:

View File

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

View File

@@ -16,12 +16,20 @@ signal email_verification_sent(email: String)
signal email_verification_failed(message: String)
signal profile_update_succeeded(profile: Dictionary)
signal profile_update_failed(message: String)
signal appearance_update_succeeded(skin_id: String, profile: Dictionary)
signal appearance_update_failed(skin_id: String, message: String)
signal session_refresh_completed(success: bool)
signal logout_completed()
signal browser_bootstrap_received(kind: String)
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const DEFAULT_AUTH_CONFIG_PATH: String = "user://auth.cfg"
const REQUEST_TIMEOUT: float = 12.0
const BROWSER_BOOTSTRAP_STORAGE_KEY: String = "whaletown.auth.bootstrap"
const BROWSER_BOOTSTRAP_POLL_INTERVAL: float = 0.25
const SESSION_REFRESH_CHECK_INTERVAL: float = 15.0
const SESSION_REFRESH_LEAD_TIME: float = 120.0
var _access_token: String = ""
var _refresh_token: String = ""
@@ -32,9 +40,33 @@ var _session_generation: int = 0
var _account_generation: int = 0
var _refresh_in_flight: bool = false
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:
_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:
for request in _active_requests:
@@ -70,6 +102,14 @@ func get_account_generation() -> int:
func get_auth_config_path() -> String:
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:
var normalized_identifier := identifier.strip_edges()
if normalized_identifier.is_empty():
@@ -85,8 +125,12 @@ func login(identifier: String, password: String) -> void:
"password": password
}, _on_login_response, HTTPClient.METHOD_POST, false, true)
func send_email_verification(email: String) -> void:
func send_email_verification(email: String, invitation_code: String = "") -> void:
var normalized_email := email.strip_edges()
var normalized_invitation_code := invitation_code.strip_edges().to_upper()
if normalized_invitation_code.is_empty():
email_verification_failed.emit("请输入邀请码")
return
if normalized_email.is_empty():
email_verification_failed.emit("请输入邮箱")
return
@@ -95,7 +139,8 @@ func send_email_verification(email: String) -> void:
return
_request_json("/auth/send-email-verification", {
"email": normalized_email
"email": normalized_email,
"invitation_code": normalized_invitation_code
}, func(success: bool, _data: Dictionary, error_info: Dictionary) -> void:
if success:
email_verification_sent.emit(normalized_email)
@@ -103,11 +148,12 @@ func send_email_verification(email: String) -> void:
email_verification_failed.emit(str(error_info.get("message", "验证码发送失败")))
)
func register(username: String, password: String, nickname: String = "", email: String = "", email_verification_code: String = "", skin_id: String = "") -> void:
func register(username: String, password: String, nickname: String = "", email: String = "", email_verification_code: String = "", skin_id: String = "", invitation_code: String = "") -> void:
var normalized_username := username.strip_edges()
var normalized_nickname := nickname.strip_edges()
var normalized_email := email.strip_edges()
var normalized_code := email_verification_code.strip_edges()
var normalized_invitation_code := invitation_code.strip_edges().to_upper()
if normalized_username.is_empty():
register_failed.emit("请输入用户名")
@@ -115,6 +161,9 @@ func register(username: String, password: String, nickname: String = "", email:
if not _is_valid_username(normalized_username):
register_failed.emit("用户名只能包含字母、数字和下划线,长度 1-50")
return
if normalized_invitation_code.is_empty():
register_failed.emit("请输入邀请码")
return
if password.length() < 8:
register_failed.emit("密码至少 8 位,并需要包含字母和数字")
return
@@ -138,12 +187,17 @@ func register(username: String, password: String, nickname: String = "", email:
"password": password,
"nickname": normalized_nickname,
"email": normalized_email,
"email_verification_code": normalized_code
"email_verification_code": normalized_code,
"invitation_code": normalized_invitation_code
}
var normalized_skin_id := skin_id.strip_edges()
if not normalized_skin_id.is_empty():
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()
_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("settings")
if profile_data.has("skin_id") and playerStateManager.has_method("update_appearance"):
var requestedSkinId := str(profile_data.get("skin_id", "")).strip_edges()
playerStateManager.call(
"update_appearance",
str(profile_data.get("skin_id", "")),
Callable(self, "_on_profile_response")
requestedSkinId,
Callable(self, "_on_appearance_profile_response").bind(requestedSkinId)
)
if profile_data.has("settings") and playerStateManager.has_method("update_settings"):
var settingsVariant: Variant = profile_data.get("settings", {})
@@ -196,14 +251,17 @@ func update_profile(profile_data: Dictionary) -> void:
if not profile_data.has("skin_id") and not profile_data.has("settings"):
profile_update_failed.emit("没有可保存的玩家资料")
func refresh_session() -> void:
if _refresh_token.strip_edges().is_empty() or _refresh_in_flight:
return
func refresh_session() -> bool:
if _refresh_token.strip_edges().is_empty():
return false
if _refresh_in_flight:
return true
_refresh_in_flight = true
_request_json("/auth/refresh-token", {
"refresh_token": _refresh_token
}, _on_refresh_response, HTTPClient.METHOD_POST, false, true)
return true
func logout() -> void:
_clear_session_memory()
@@ -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:
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
_apply_auth_payload(data)
if not is_authenticated():
_clear_pending_registration()
register_failed.emit("注册响应缺少 access_token")
return
_clear_pending_registration()
_emit_event(EventNames.AUTH_REGISTER_SUCCESS, {
"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())
_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:
_refresh_in_flight = false
if not success:
session_refresh_completed.emit(false)
logout()
return
_apply_auth_payload(data)
session_refresh_completed.emit(true)
auth_state_changed.emit(true, get_current_user())
_refresh_player_snapshot()
@@ -322,6 +432,11 @@ func _apply_auth_payload(payload: Dictionary) -> void:
var auth_data: Dictionary = data_variant
_access_token = str(auth_data.get("access_token", "")).strip_edges()
_refresh_token = str(auth_data.get("refresh_token", _refresh_token)).strip_edges()
_access_token_expires_at = _jwt_expiration_time(_access_token)
if _access_token_expires_at <= 0.0:
var expiresIn := float(auth_data.get("expires_in", 0.0))
if expiresIn > 0.0:
_access_token_expires_at = Time.get_unix_time_from_system() + expiresIn
var user_variant: Variant = auth_data.get("user", {})
if user_variant is Dictionary:
@@ -334,6 +449,38 @@ func _apply_auth_payload(payload: Dictionary) -> void:
_session_generation += 1
_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:
if not success:
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())
auth_state_changed.emit(is_authenticated(), get_current_user())
func _on_appearance_profile_response(
success: bool,
data: Dictionary,
errorInfo: Dictionary,
requestedSkinId: String
) -> void:
if not success:
var message := str(errorInfo.get("message", "玩家资料保存失败"))
appearance_update_failed.emit(requestedSkinId, message)
profile_update_failed.emit(message)
auth_state_changed.emit(is_authenticated(), get_current_user())
return
var dataVariant: Variant = data.get("data", {})
if not (dataVariant is Dictionary):
var message := "玩家状态响应格式错误"
appearance_update_failed.emit(requestedSkinId, message)
profile_update_failed.emit(message)
auth_state_changed.emit(is_authenticated(), get_current_user())
return
var snapshot := dataVariant as Dictionary
var playerStateManager := get_node_or_null("/root/PlayerStateManager")
if playerStateManager != null and playerStateManager.has_method("apply_snapshot"):
playerStateManager.call("apply_snapshot", snapshot)
_apply_snapshot_profile_payload(snapshot)
var profile := get_current_profile()
appearance_update_succeeded.emit(requestedSkinId, profile)
profile_update_succeeded.emit(profile)
auth_state_changed.emit(is_authenticated(), get_current_user())
func _on_profile_assets_response(success: bool, data: Dictionary, _error_info: Dictionary, requestGeneration: int) -> void:
if requestGeneration != _account_generation:
return
@@ -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()
_access_token = str(config.get_value("auth", "access_token", "")).strip_edges()
_access_token_expires_at = _jwt_expiration_time(_access_token)
_current_user.clear()
_current_profile.clear()
@@ -441,6 +619,7 @@ func _emit_cached_auth_state() -> void:
func _clear_session_memory() -> void:
_access_token = ""
_refresh_token = ""
_access_token_expires_at = 0.0
_current_user.clear()
_current_profile.clear()
_advance_account_generation()
@@ -497,6 +676,26 @@ func _password_has_letter_and_number(password: String) -> bool:
has_number = true
return has_letter and has_number
func _token_needs_refresh() -> bool:
if _refresh_in_flight or _refresh_token.is_empty():
return false
if _access_token_expires_at <= 0.0:
return false
return Time.get_unix_time_from_system() + SESSION_REFRESH_LEAD_TIME >= _access_token_expires_at
func _jwt_expiration_time(token: String) -> float:
var parts := token.split(".")
if parts.size() != 3:
return 0.0
var payload := str(parts[1]).replace("-", "+").replace("_", "/")
while payload.length() % 4 != 0:
payload += "="
var jsonText := Marshalls.base64_to_raw(payload).get_string_from_utf8()
var json := JSON.new()
if json.parse(jsonText) != OK or not (json.data is Dictionary):
return 0.0
return float((json.data as Dictionary).get("exp", 0.0))
func _http_result_to_string(result: int) -> String:
match result:
HTTPRequest.RESULT_SUCCESS:

View File

@@ -66,11 +66,13 @@ const CHAT_WEBSOCKET_MANAGER_SCRIPT: Script = preload("res://_Core/managers/WebS
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
# WebSocket 服务器 URL原生 WebSocket
const WEBSOCKET_URL: String = "wss://whaletownend.xinghangee.icu/game"
const WEBSOCKET_URL: String = "wss://whaletown.novamailio.com/game"
# 重连配置
const RECONNECT_MAX_ATTEMPTS: int = 5
const RECONNECT_BASE_DELAY: float = 3.0
const APPEARANCE_BROADCAST_RETRY_DELAY: float = 1.5
const APPEARANCE_BROADCAST_MAX_RETRIES: int = 5
# 频率限制配置
const RATE_LIMIT_MESSAGES: int = 10
@@ -98,7 +100,9 @@ const CHAT_ERROR_MESSAGES: Dictionary = {
"PERMISSION_DENIED": "您没有权限发送消息",
"SESSION_EXPIRED": "会话已过期,请重新连接",
"ZULIP_ERROR": "消息服务暂时不可用",
"INTERNAL_ERROR": "服务器内部错误"
"INTERNAL_ERROR": "服务器内部错误",
"INSUFFICIENT_BALANCE": "鲸币余额不足,发布世界公告需要 100 鲸币",
"WALLET_UNAVAILABLE": "鲸币服务暂不可用,请稍后再试"
}
# ============================================================================
@@ -125,11 +129,18 @@ var _message_timestamps: Array[float] = []
# 当前用户信息
var _current_username: String = ""
var _current_user_id: String = ""
var _current_map: String = ""
var _world_ready_map: String = ""
var _world_ready_confirmed: bool = false
var _appearance_broadcast_pending: bool = false
var _appearance_broadcast_skin_id: String = ""
var _appearance_broadcast_retry_count: int = 0
var _appearance_broadcast_retry_timer: Timer
# 游戏 token
var _game_token: String = ""
var _guest_mode: bool = false
# 连接/登录完成前暂存用户已经提交的消息,避免刚进游戏时第一条消息被丢掉。
var _queued_messages: Array[Dictionary] = []
@@ -164,6 +175,10 @@ func _ready() -> void:
_history_request.timeout = 12.0
_history_request.request_completed.connect(_on_history_request_completed)
add_child(_history_request)
_appearance_broadcast_retry_timer = Timer.new()
_appearance_broadcast_retry_timer.one_shot = true
_appearance_broadcast_retry_timer.timeout.connect(_on_appearance_broadcast_retry_timeout)
add_child(_appearance_broadcast_retry_timer)
# 连接信号
_connect_signals()
@@ -209,13 +224,24 @@ func set_game_token(token: String) -> void:
_queued_messages.clear()
_recoverable_messages.clear()
_world_ready_map = ""
_world_ready_confirmed = false
_clear_pending_appearance_broadcast()
func is_logged_in() -> bool:
return _is_logged_in
func mark_world_ready(mapId: String, position: Vector2) -> void:
func is_guest_mode() -> bool:
return _guest_mode
func start_guest_session() -> void:
_guest_mode = true
_game_token = ""
_is_logged_in = false
connect_to_chat_server()
func mark_world_ready(mapId: String, position: Vector2, direction: String = "down", movementState: String = "idle", sequence: int = 0) -> void:
var normalizedMapId := mapId.strip_edges()
if not _is_logged_in or normalizedMapId.is_empty() or _world_ready_map == normalizedMapId:
if not _is_logged_in or normalizedMapId.is_empty() or (_world_ready_map == normalizedMapId and _world_ready_confirmed):
return
var payload := {
@@ -223,9 +249,13 @@ func mark_world_ready(mapId: String, position: Vector2) -> void:
"mapId": normalizedMapId,
"x": position.x,
"y": position.y,
"direction": direction,
"movementState": movementState,
"sequence": sequence,
}
if _websocket_manager.send_message(JSON.stringify(payload)) == OK:
_world_ready_map = normalizedMapId
_world_ready_confirmed = false
func leave_world(sceneId: String = "private_space") -> void:
if not _is_logged_in or _world_ready_map.is_empty():
@@ -239,11 +269,60 @@ func leave_world(sceneId: String = "private_space") -> void:
}
if _websocket_manager.send_message(JSON.stringify(payload)) == OK:
_world_ready_map = ""
_world_ready_confirmed = false
_stop_appearance_broadcast_retry()
func notify_appearance_changed() -> void:
if not _is_logged_in or _world_ready_map.is_empty():
func notify_appearance_changed(skinId: String = "") -> void:
var normalizedSkinId := skinId.strip_edges()
if not normalizedSkinId.is_empty():
_appearance_broadcast_skin_id = normalizedSkinId
_appearance_broadcast_pending = true
_appearance_broadcast_retry_count = 0
_try_send_pending_appearance_broadcast()
func _try_send_pending_appearance_broadcast() -> void:
if not _appearance_broadcast_pending or not _is_logged_in or not _world_ready_confirmed:
return
if _world_ready_map.is_empty() or _appearance_broadcast_retry_count >= APPEARANCE_BROADCAST_MAX_RETRIES:
return
_appearance_broadcast_retry_count += 1
_websocket_manager.send_message(JSON.stringify({"type": "appearance_changed"}))
_schedule_appearance_broadcast_retry()
func _schedule_appearance_broadcast_retry() -> void:
if _appearance_broadcast_retry_count >= APPEARANCE_BROADCAST_MAX_RETRIES:
return
if is_instance_valid(_appearance_broadcast_retry_timer):
_appearance_broadcast_retry_timer.start(APPEARANCE_BROADCAST_RETRY_DELAY)
func _stop_appearance_broadcast_retry() -> void:
if is_instance_valid(_appearance_broadcast_retry_timer):
_appearance_broadcast_retry_timer.stop()
func _clear_pending_appearance_broadcast() -> void:
_appearance_broadcast_pending = false
_appearance_broadcast_skin_id = ""
_appearance_broadcast_retry_count = 0
_stop_appearance_broadcast_retry()
func _on_appearance_broadcast_retry_timeout() -> void:
_try_send_pending_appearance_broadcast()
func _handle_world_ready_success(data: Dictionary) -> void:
var confirmedMapId := str(data.get("mapId", data.get("map_id", ""))).strip_edges()
if not confirmedMapId.is_empty():
_world_ready_map = confirmedMapId
_world_ready_confirmed = not _world_ready_map.is_empty()
if _appearance_broadcast_pending:
_appearance_broadcast_retry_count = 0
_try_send_pending_appearance_broadcast()
func _handle_appearance_changed_success(data: Dictionary) -> void:
var confirmedSkinId := str(data.get("skinId", data.get("skin_id", ""))).strip_edges()
if not _appearance_broadcast_skin_id.is_empty() and confirmedSkinId != _appearance_broadcast_skin_id:
_schedule_appearance_broadcast_retry()
return
_clear_pending_appearance_broadcast()
# 获取游戏 token
#
@@ -267,12 +346,12 @@ func connect_to_chat_server() -> void:
push_warning("聊天服务器正在连接")
return
if _game_token.strip_edges().is_empty():
if not _guest_mode and _game_token.strip_edges().is_empty():
var authToken := _get_auth_manager_token()
if not authToken.is_empty():
_game_token = authToken
if _game_token.strip_edges().is_empty():
if not _guest_mode and _game_token.strip_edges().is_empty():
_handle_error("AUTH_FAILED", "请先登录后再使用聊天")
return
@@ -291,6 +370,7 @@ func disconnect_from_chat_server() -> void:
# 断开连接
_websocket_manager.disconnect_websocket()
_guest_mode = false
# 检查是否已连接
#
@@ -325,6 +405,49 @@ func can_attempt_chat_connection() -> bool:
func send_chat_message(content: String, scope: String = "local", show_bubble: bool = false) -> bool:
return _send_chat_payload(content, scope, {"bubble": show_bubble and _settings_bool("show_chat_bubbles", true)})
## Publish a paid world announcement. The server owns validation and charging.
func send_world_bulletin(content: String) -> bool:
if not _is_logged_in:
_handle_error("AUTH_FAILED", "请先登录后再发布世界公告")
return false
# 客户端只表达发布意图;价格、实时余额校验和扣费全由服务端处理。
return _send_chat_payload(content, "global", {
"bubble": false,
"worldBulletin": true,
"_suppressLocalEcho": true,
"_nonRecoverable": true
})
func interact_with_world_npc(npc_id: String, message: String = "", session_id: String = "") -> bool:
var normalized_npc_id := npc_id.strip_edges()
if normalized_npc_id.is_empty() or not _is_logged_in:
return false
if not _websocket_manager.is_websocket_connected():
connect_to_chat_server()
return false
var payload := {
"type": "npc_interact",
"npcId": normalized_npc_id,
"message": message.strip_edges().left(300),
}
if not session_id.strip_edges().is_empty():
payload["sessionId"] = session_id.strip_edges()
var send_err: Error = _websocket_manager.send_message(JSON.stringify(payload))
if send_err != OK:
_handle_error("SEND_FAILED", "WebSocket send failed: %s" % error_string(send_err))
return false
return true
func end_world_npc_session(npc_id: String, session_id: String = "") -> bool:
if npc_id.strip_edges().is_empty() or not _is_logged_in or not _websocket_manager.is_websocket_connected():
return false
return _websocket_manager.send_message(JSON.stringify({
"type": "npc_session_end", "npcId": npc_id.strip_edges(), "sessionId": session_id.strip_edges(),
})) == OK
func get_current_user_id() -> String:
return _current_user_id
func send_private_message(content: String, target_user_id: String, target_username: String = "", private_context: String = "") -> bool:
if not _settings_bool("allow_nearby_private", true) and private_context.strip_edges() == "whisper":
_handle_error("PRIVATE_DISABLED", "附近私聊已在设置中关闭")
@@ -449,6 +572,7 @@ func _send_chat_payload(content: String, scope: String = "local", extra_data: Di
var show_bubble: bool = bool(extra_data.get("bubble", extra_data.get("showBubble", extra_data.get("show_bubble", false)))) and _settings_bool("show_chat_bubbles", true)
var suppress_local_echo := bool(extra_data.get("_suppressLocalEcho", false))
var is_recovery_retry := bool(extra_data.get("_isRecoveryRetry", false))
var non_recoverable := bool(extra_data.get("_nonRecoverable", false))
var recoverable_extra_data := extra_data.duplicate(true)
recoverable_extra_data.erase("_suppressLocalEcho")
@@ -501,6 +625,7 @@ func _send_chat_payload(content: String, scope: String = "local", extra_data: Di
if send_err != OK:
_handle_error("SEND_FAILED", "WebSocket send failed: %s" % error_string(send_err))
return false
if not non_recoverable:
_track_recoverable_message({
"content": normalized_content,
"scope": scope,
@@ -575,10 +700,12 @@ func _on_chat_message_sent(_request_id: String, success: bool, data: Dictionary,
#
# 使用示例:
# ChatManager.update_player_position(150.0, 200.0, "novice_village")
func update_player_position(x: float, y: float, map_id: String) -> void:
update_player_position_with_appearance(x, y, map_id, {})
func update_player_position(x: float, y: float, map_id: String, direction: String = "down", movementState: String = "walk", sequence: int = 0) -> void:
update_player_position_with_appearance(x, y, map_id, {}, direction, movementState, sequence)
func update_player_position_with_appearance(x: float, y: float, map_id: String, _appearance: Dictionary = {}) -> void:
func update_player_position_with_appearance(x: float, y: float, map_id: String, _appearance: Dictionary = {}, direction: String = "down", movementState: String = "walk", sequence: int = 0) -> void:
if _guest_mode:
return
if not _websocket_manager.is_websocket_connected():
connect_to_chat_server()
return
@@ -589,7 +716,10 @@ func update_player_position_with_appearance(x: float, y: float, map_id: String,
"type": "position",
"x": x,
"y": y,
"mapId": map_id
"mapId": map_id,
"direction": direction,
"movementState": movementState,
"sequence": sequence
}
# 发送消息JSON 字符串)
var json_string := JSON.stringify(position_data)
@@ -604,6 +734,8 @@ func update_player_position_with_appearance(x: float, y: float, map_id: String,
# 返回值:
# bool - 是否可以发送
func can_send_message() -> bool:
if _guest_mode:
return false
var current_time := Time.get_unix_time_from_system()
# 清理过期的时间戳
@@ -794,6 +926,8 @@ func _connect_signals() -> void:
func _on_connection_lost() -> void:
_is_logged_in = false
_world_ready_map = ""
_world_ready_confirmed = false
_stop_appearance_broadcast_retry()
_restore_recoverable_messages_to_queue()
func _load_token_from_environment() -> void:
@@ -911,6 +1045,9 @@ func _preview_text(text: String) -> String:
# 发送登录消息
func _send_login_message() -> void:
if _guest_mode:
_websocket_manager.send_message(JSON.stringify({"type": "guest_login"}))
return
if _game_token.strip_edges().is_empty():
_handle_error("AUTH_FAILED", "缺少聊天认证 token")
return
@@ -967,8 +1104,12 @@ func _on_data_received(message: String) -> void:
match message_type:
"connected":
pass
"pong":
pass
"login_success":
_handle_login_success(data)
"guest_login_success":
_handle_guest_login_success(data)
"login_error":
_handle_login_error(data)
"chat":
@@ -997,6 +1138,50 @@ func _on_data_received(message: String) -> void:
_handle_friend_error(data)
"map_players_snapshot":
_handle_map_players_snapshot(data)
"npc_snapshot":
_handle_npc_snapshot(data)
"npc_action_started":
_emit_event(EventNames.NPC_ACTION_STARTED, _normalize_npc_action_event(data))
"npc_action_completed":
_emit_event(EventNames.NPC_ACTION_COMPLETED, _normalize_npc_action_event(data))
"npc_spoke":
_emit_event(EventNames.NPC_SPOKE, {
"npc_id": str(data.get("npcId", data.get("npc_id", ""))),
"npc_name": str(data.get("npcName", data.get("npc_name", "NPC"))),
"response": str(data.get("response", "")),
"public_intention": str(data.get("publicIntention", data.get("public_intention", ""))),
"activity": data.get("activity", {}),
"memory_id": str(data.get("memoryId", data.get("memory_id", ""))),
"target_user_id": str(data.get("targetUserId", data.get("target_user_id", ""))),
"target_username": str(data.get("targetUsername", data.get("target_username", ""))),
"session_id": str(data.get("sessionId", data.get("session_id", ""))),
})
"npc_interaction_success":
pass
"npc_interaction_error":
_emit_event(EventNames.NPC_INTERACTION_ERROR, {
"npc_id": str(data.get("npcId", data.get("npc_id", ""))),
"code": str(data.get("code", "INTERACTION_REJECTED")),
"message": str(data.get("message", "NPC暂时无法回应")),
})
"npc_conversation":
var conversationLines: Array[Dictionary] = []
var rawLines: Variant = data.get("lines", [])
if rawLines is Array:
for rawLine in rawLines:
if rawLine is Dictionary:
conversationLines.append({
"speaker_npc_id": str(rawLine.get("speakerNpcId", rawLine.get("speaker_npc_id", ""))),
"speaker_name": str(rawLine.get("speakerName", rawLine.get("speaker_name", "NPC"))),
"text": str(rawLine.get("text", "")),
})
_emit_event(EventNames.NPC_CONVERSATION, {
"conversation_id": str(data.get("conversationId", data.get("conversation_id", ""))),
"encounter_id": str(data.get("encounterId", data.get("encounter_id", ""))),
"mapId": str(data.get("mapId", data.get("map_id", ""))),
"location_id": str(data.get("locationId", data.get("location_id", ""))),
"lines": conversationLines,
})
"system_presence":
_handle_system_presence(data)
"position_update":
@@ -1006,9 +1191,13 @@ func _on_data_received(message: String) -> void:
"appearance_changed":
_handle_player_joined(data)
"world_ready_success":
pass
_handle_world_ready_success(data)
"appearance_changed_success":
_handle_appearance_changed_success(data)
"world_left":
_world_ready_map = ""
_world_ready_confirmed = false
_stop_appearance_broadcast_retry()
"player_left":
_handle_player_left(data)
"position_updated":
@@ -1031,13 +1220,17 @@ func _warn_empty_message_type_limited(data: Dictionary) -> void:
# 处理登录成功
func _handle_login_success(data: Dictionary) -> void:
_guest_mode = false
_is_logged_in = true
_refreshing_auth_session = false
_chat_login_refresh_attempted = false
_friend_protocol_supported = true
_current_username = data.get("username", "")
_current_user_id = str(data.get("userId", data.get("user_id", "")))
_current_map = data.get("currentMap", "")
_world_ready_map = ""
_world_ready_confirmed = false
_stop_appearance_broadcast_retry()
# 重置当前会话缓存(每次登录/重连都清空,重新开始接收消息)
reset_session()
@@ -1051,6 +1244,18 @@ func _handle_login_success(data: Dictionary) -> void:
_flush_queued_messages()
request_friend_list()
func _handle_guest_login_success(data: Dictionary) -> void:
_guest_mode = true
_is_logged_in = true
_friend_protocol_supported = false
_current_username = "游客"
_current_user_id = ""
_current_map = str(data.get("currentMap", "whale_port"))
_world_ready_map = ""
_world_ready_confirmed = false
reset_session()
_emit_event(EventNames.CHAT_LOGIN_SUCCESS, {"username": "游客", "current_map": _current_map, "guest": true})
func _handle_system_presence(data: Dictionary) -> void:
var content := str(data.get("content", "")).strip_edges()
if content.is_empty():
@@ -1070,8 +1275,8 @@ func _handle_system_presence(data: Dictionary) -> void:
chat_message_received.emit("系统", content, false, timestamp)
_emit_event(EventNames.CHAT_MESSAGE_RECEIVED, message)
func _on_account_profile_updated(_profile: Dictionary) -> void:
notify_appearance_changed()
func _on_account_profile_updated(profile: Dictionary) -> void:
notify_appearance_changed(str(profile.get("skin_id", "")))
func _on_auth_state_changed(isAuthenticated: bool, _user: Dictionary) -> void:
if not isAuthenticated:
@@ -1111,6 +1316,19 @@ func _handle_login_error(data: Dictionary) -> void:
func _handle_chat_sent(data: Dictionary) -> void:
var message_id: String = str(data.get("messageId", ""))
var timestamp: float = data.get("timestamp", 0.0)
var is_world_bulletin: bool = bool(data.get("worldBulletin", data.get("world_bulletin", false)))
if is_world_bulletin and data.has("balance"):
# 直接采用这次后端交易返回的余额,不在客户端自行计算扣费。
var player_state_manager := get_node_or_null("/root/PlayerStateManager")
if player_state_manager != null and player_state_manager.has_method("apply_wallet"):
var wallet: Dictionary = {}
if player_state_manager.has_method("get_wallet"):
var current_wallet: Variant = player_state_manager.call("get_wallet")
if current_wallet is Dictionary:
wallet = (current_wallet as Dictionary).duplicate(true)
wallet["balance"] = int(data.get("balance", 0))
wallet["currency"] = "whale_coin"
player_state_manager.call("apply_wallet", wallet)
# 发射信号
chat_message_sent.emit(message_id, timestamp)
@@ -1118,24 +1336,28 @@ func _handle_chat_sent(data: Dictionary) -> void:
# 通过 EventSystem 广播Signal Up
_emit_event(EventNames.CHAT_MESSAGE_SENT, {
"message_id": message_id,
"timestamp": timestamp
"timestamp": timestamp,
"world_bulletin": is_world_bulletin,
"charged": int(data.get("charged", 0)),
"balance": int(data.get("balance", 0))
})
if not _recoverable_messages.is_empty():
if not is_world_bulletin and not _recoverable_messages.is_empty():
_recoverable_messages.pop_front()
# 处理聊天消息发送失败
func _handle_chat_error(data: Dictionary) -> void:
var error_message: String = data.get("message", "消息发送失败")
var error_code: String = str(data.get("code", data.get("error_code", "CHAT_SEND_FAILED"))).strip_edges()
if error_code == "SESSION_EXPIRED":
_handle_session_expired(error_message)
return
var is_world_bulletin: bool = bool(data.get("worldBulletin", data.get("world_bulletin", false)))
# 通过 EventSystem 广播错误Signal Up
_emit_event(EventNames.CHAT_ERROR_OCCURRED, {
"error_code": error_code if not error_code.is_empty() else "CHAT_SEND_FAILED",
"message": error_message
"message": error_message,
"world_bulletin": is_world_bulletin
})
if error_code == "SESSION_EXPIRED":
_handle_session_expired(error_message)
func _handle_friend_list(data: Dictionary) -> void:
_friends.clear()
@@ -1256,6 +1478,7 @@ func _handle_chat_render(data: Dictionary) -> void:
var to_username: String = str(data.get("toUsername", data.get("to_username", data.get("targetUsername", data.get("target_username", ""))))).strip_edges()
var private_context: String = str(data.get("privateContext", data.get("private_context", ""))).strip_edges()
var is_private: bool = scope == "private"
var is_world_bulletin: bool = bool(data.get("worldBulletin", data.get("world_bulletin", false)))
var timestamp: float = _parse_chat_timestamp_to_unix(data.get("timestamp", 0.0))
@@ -1280,7 +1503,8 @@ func _handle_chat_render(data: Dictionary) -> void:
"to_user_id": to_user_id,
"to_username": to_username,
"private_context": private_context,
"is_private": is_private
"is_private": is_private,
"world_bulletin": is_world_bulletin
})
# 发射信号
@@ -1298,7 +1522,8 @@ func _handle_chat_render(data: Dictionary) -> void:
"to_user_id": to_user_id,
"to_username": to_username,
"private_context": private_context,
"is_private": is_private
"is_private": is_private,
"world_bulletin": is_world_bulletin
})
# 解析聊天消息时间戳(兼容 unix 秒 / ISO 8601 字符串)
@@ -1359,6 +1584,9 @@ func _handle_position_update(data: Dictionary) -> void:
"userId": user_id,
"username": str(data.get("username", "")),
"position": Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))),
"direction": str(data.get("direction", "down")),
"movement_state": str(data.get("movementState", data.get("movement_state", "walk"))),
"sequence": int(data.get("sequence", -1)),
"mapId": str(data.get("mapId", data.get("map_id", ""))),
"skin_id": str(appearance.get("skin_id", "")),
"avatar_id": str(appearance.get("avatar_id", "")),
@@ -1383,6 +1611,9 @@ func _handle_map_players_snapshot(data: Dictionary) -> void:
"userId": user_id,
"username": str(player.get("username", "")),
"position": Vector2(float(player.get("x", 0.0)), float(player.get("y", 0.0))),
"direction": str(player.get("direction", "down")),
"movement_state": str(player.get("movementState", player.get("movement_state", "idle"))),
"sequence": int(player.get("sequence", -1)),
"mapId": str(player.get("mapId", player.get("map_id", data.get("mapId", data.get("map_id", ""))))),
"skin_id": str(appearance.get("skin_id", "")),
"avatar_id": str(appearance.get("avatar_id", "")),
@@ -1396,6 +1627,72 @@ func _handle_map_players_snapshot(data: Dictionary) -> void:
"players": players
})
func _handle_npc_snapshot(data: Dictionary) -> void:
var npcs: Array[Dictionary] = []
var npcsVariant: Variant = data.get("npcs", [])
if npcsVariant is Array:
for npcVariant in npcsVariant:
if not (npcVariant is Dictionary):
continue
var npc: Dictionary = npcVariant
var npcId := str(npc.get("npcId", npc.get("npc_id", ""))).strip_edges()
if npcId.is_empty():
continue
npcs.append({
"npc_id": npcId,
"name": str(npc.get("name", "NPC")),
"x": float(npc.get("x", 0.0)),
"y": float(npc.get("y", 0.0)),
"direction": str(npc.get("direction", "down")),
"movement_state": str(npc.get("movementState", npc.get("movement_state", "idle"))),
"state": str(npc.get("state", "idle")),
"version": int(npc.get("version", 0)),
"daily_goal": str(npc.get("dailyGoal", npc.get("daily_goal", ""))),
"plan_source": str(npc.get("planSource", npc.get("plan_source", "fallback"))),
"current_activity": npc.get("currentActivity", npc.get("current_activity", {})),
"public_intention": str(npc.get("publicIntention", npc.get("public_intention", ""))),
"dialogue": str(npc.get("dialogue", "")),
"scene": str(npc.get("scene", "classic_whale")),
"active_action": _normalize_npc_action(npc.get("activeAction", npc.get("active_action", null))),
})
_emit_event(EventNames.NPC_SNAPSHOT_READY, {
"mapId": str(data.get("mapId", data.get("map_id", ""))),
"server_now": int(data.get("serverNow", data.get("server_now", 0))),
"version": int(data.get("version", 0)),
"npcs": npcs,
})
func _normalize_npc_action_event(data: Dictionary) -> Dictionary:
return {
"mapId": str(data.get("mapId", data.get("map_id", ""))),
"server_now": int(data.get("serverNow", data.get("server_now", 0))),
"npc_id": str(data.get("npcId", data.get("npc_id", ""))),
"action": _normalize_npc_action(data.get("action", {})),
}
func _normalize_npc_action(value: Variant) -> Dictionary:
if not (value is Dictionary):
return {}
var action: Dictionary = value
return {
"action_id": str(action.get("actionId", action.get("action_id", ""))),
"kind": str(action.get("kind", "walk")),
"from_x": float(action.get("fromX", action.get("from_x", 0.0))),
"from_y": float(action.get("fromY", action.get("from_y", 0.0))),
"to_x": float(action.get("toX", action.get("to_x", 0.0))),
"to_y": float(action.get("toY", action.get("to_y", 0.0))),
"from_map_id": str(action.get("fromMapId", action.get("from_map_id", ""))),
"to_map_id": str(action.get("toMapId", action.get("to_map_id", ""))),
"from_location_id": str(action.get("fromLocationId", action.get("from_location_id", ""))),
"to_location_id": str(action.get("toLocationId", action.get("to_location_id", ""))),
"activity_id": str(action.get("activityId", action.get("activity_id", ""))),
"activity_kind": str(action.get("activityKind", action.get("activity_kind", ""))),
"started_at": int(action.get("startedAt", action.get("started_at", 0))),
"completes_at": int(action.get("completesAt", action.get("completes_at", 0))),
"version": int(action.get("version", 0)),
}
func _handle_player_joined(data: Dictionary) -> void:
var user_id := str(data.get("userId", data.get("user_id", ""))).strip_edges()
if user_id.is_empty():
@@ -1406,6 +1703,9 @@ func _handle_player_joined(data: Dictionary) -> void:
"userId": user_id,
"username": str(data.get("username", "")),
"position": Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))),
"direction": str(data.get("direction", "down")),
"movement_state": str(data.get("movementState", data.get("movement_state", "idle"))),
"sequence": int(data.get("sequence", -1)),
"mapId": str(data.get("mapId", data.get("map_id", ""))),
"skin_id": str(appearance.get("skin_id", "")),
"avatar_id": str(appearance.get("avatar_id", "")),
@@ -1417,8 +1717,11 @@ func _handle_player_joined(data: Dictionary) -> void:
func _normalize_player_appearance(data: Dictionary) -> Dictionary:
var appearanceVariant: Variant = data.get("appearance", {})
var appearance: Dictionary = appearanceVariant if appearanceVariant is Dictionary else {}
var skinId := str(data.get("skinId", data.get("skin_id", appearance.get("skinId", appearance.get("skin_id", ""))))).strip_edges()
if skinId.is_empty() or skinId == "pending_initial_skin":
skinId = "classic_whale"
return {
"skin_id": str(data.get("skinId", data.get("skin_id", appearance.get("skinId", appearance.get("skin_id", ""))))),
"skin_id": skinId,
"avatar_id": str(data.get("avatarId", data.get("avatar_id", appearance.get("avatarId", appearance.get("avatar_id", ""))))),
"skin_asset": data.get("skinAsset", data.get("skin_asset", appearance.get("skinAsset", appearance.get("skin_asset", {})))),
}

View File

@@ -0,0 +1,201 @@
extends Node
const FOCUS_BORDER_COLOR := Color(0.12, 0.49, 0.82, 1.0)
const CARET_COLOR := Color(0.04, 0.31, 0.60, 1.0)
const SELECTION_COLOR := Color(0.25, 0.61, 0.91, 0.30)
const STYLED_META := &"whaletown_input_focus_styled"
const INITIAL_PLACEHOLDER_META := &"whaletown_initial_placeholder"
const FOCUS_PLACEHOLDER_META := &"whaletown_focus_placeholder"
const NATIVE_INPUT_ID := "whaletown-native-input"
var _javascript_callback: Variant
var _native_inputs: Dictionary = {}
var _active_native_input: Control
var _bridge_initialized := false
func _ready() -> void:
get_tree().node_added.connect(_on_node_added)
call_deferred("_initialize_javascript_bridge")
call_deferred("_decorate_existing_inputs")
func _process(_delta: float) -> void:
if not _bridge_initialized:
_initialize_javascript_bridge()
func _initialize_javascript_bridge() -> void:
if _bridge_initialized or OS.get_name() != "Web":
return
var window: Variant = JavaScriptBridge.get_interface("window")
if window == null:
return
_javascript_callback = JavaScriptBridge.create_callback(_on_native_input_event)
window["__whaletownNativeInputCallback"] = _javascript_callback
_bridge_initialized = true
func _exit_tree() -> void:
_close_native_input()
if _bridge_initialized:
var window: Variant = JavaScriptBridge.get_interface("window")
if window != null:
window["__whaletownNativeInputCallback"] = null
func _on_node_added(node: Node) -> void:
if node is LineEdit or node is TextEdit:
_decorate_input.call_deferred(node)
func _decorate_existing_inputs() -> void:
_decorate_branch(get_tree().root)
func _decorate_branch(node: Node) -> void:
if node is LineEdit or node is TextEdit:
_decorate_input(node)
for child in node.get_children():
_decorate_branch(child)
func _decorate_input(input: Control) -> void:
if not is_instance_valid(input) or input.has_meta(STYLED_META):
return
input.set_meta(STYLED_META, true)
input.add_theme_stylebox_override("focus", _create_focus_style(input))
input.add_theme_color_override("caret_color", CARET_COLOR)
input.add_theme_color_override("selection_color", SELECTION_COLOR)
input.add_theme_constant_override("caret_width", 2)
input.set_meta(INITIAL_PLACEHOLDER_META, input.placeholder_text)
input.focus_entered.connect(_on_input_focus_entered.bind(input))
input.focus_exited.connect(_on_input_focus_exited.bind(input))
if input is LineEdit:
(input as LineEdit).text_changed.connect(func(_value: String) -> void: _sync_native_input(input))
else:
(input as TextEdit).text_changed.connect(func() -> void: _sync_native_input(input))
func _on_input_focus_entered(input: Control) -> void:
if not is_instance_valid(input):
return
if input.text.strip_edges().is_empty():
input.set_meta(FOCUS_PLACEHOLDER_META, input.placeholder_text)
input.placeholder_text = ""
_open_native_input(input)
func _on_input_focus_exited(input: Control) -> void:
if not is_instance_valid(input) or not input.text.strip_edges().is_empty():
return
var placeholder := str(input.get_meta(FOCUS_PLACEHOLDER_META, input.get_meta(INITIAL_PLACEHOLDER_META, "")))
if not placeholder.is_empty():
input.placeholder_text = placeholder
func _open_native_input(input: Control) -> void:
if not _bridge_initialized or not is_instance_valid(input):
return
_close_native_input()
_active_native_input = input
_native_inputs[input.get_instance_id()] = input
var callback_name := "window.__whaletownNativeInputCallback"
var input_id := str(input.get_instance_id())
var initial_text: String = input.text
var multiline := input is TextEdit
var secret := input is LineEdit and (input as LineEdit).secret
var rect := input.get_global_rect()
var viewport_size := get_viewport().get_visible_rect().size
var script := """
(() => {
const callback = %s;
if (typeof callback !== 'function') return false;
const previous = document.getElementById('%s');
if (previous) previous.remove();
const target = document.createElement(%s ? 'textarea' : 'input');
target.id = '%s';
target.value = %s;
target.spellcheck = false;
target.autocomplete = 'off';
if (%s) target.type = 'password';
const rect = {x: %f, y: %f, width: %f, height: %f};
const viewport = {width: %f, height: %f};
const canvas = document.getElementById('canvas');
const position = () => {
const bounds = canvas.getBoundingClientRect();
const sx = bounds.width / viewport.width;
const sy = bounds.height / viewport.height;
target.style.left = (bounds.left + rect.x * sx) + 'px';
target.style.top = (bounds.top + rect.y * sy) + 'px';
target.style.width = (rect.width * sx) + 'px';
target.style.height = (rect.height * sy) + 'px';
};
Object.assign(target.style, {
position: 'fixed', zIndex: '10000',
opacity: '0.01', color: 'transparent', caretColor: 'transparent',
background: 'transparent', border: '0', outline: '0', padding: '0',
margin: '0', resize: 'none', WebkitTextFillColor: 'transparent'
});
const send = (kind) => callback(kind, %s, target.value,
target.selectionStart || 0, target.selectionEnd || 0);
target.addEventListener('input', () => send('input'));
target.addEventListener('blur', () => send('blur'));
target.addEventListener('keydown', (event) => {
if (!%s && event.key === 'Enter') { event.preventDefault(); send('submit'); }
if (event.key === 'Escape') { event.preventDefault(); send('cancel'); }
});
document.querySelectorAll('.ime').forEach((element) => element.remove());
document.body.appendChild(target);
position();
window.addEventListener('resize', position, {passive: true});
target.__whaletownPosition = position;
target.focus({preventScroll: true});
target.setSelectionRange(target.value.length, target.value.length);
return true;
})()
""" % [callback_name, NATIVE_INPUT_ID, str(multiline).to_lower(), NATIVE_INPUT_ID,
JSON.stringify(initial_text), str(secret).to_lower(), rect.position.x, rect.position.y,
rect.size.x, rect.size.y, viewport_size.x, viewport_size.y, JSON.stringify(input_id), str(multiline).to_lower()]
var opened: Variant = JavaScriptBridge.eval(script, true)
if opened != true:
_native_inputs.erase(input.get_instance_id())
_active_native_input = null
func _close_native_input() -> void:
if _bridge_initialized:
JavaScriptBridge.eval("(() => { const el = document.getElementById('%s'); if (el) { if (el.__whaletownPosition) window.removeEventListener('resize', el.__whaletownPosition); el.remove(); } })()" % NATIVE_INPUT_ID, true)
_native_inputs.clear()
_active_native_input = null
func _sync_native_input(input: Control) -> void:
if not _bridge_initialized or input != _active_native_input or not is_instance_valid(input):
return
var value: String = input.text
var script := "(() => { const el = document.getElementById('%s'); if (el && el.value !== %s) el.value = %s; })()" % [NATIVE_INPUT_ID, JSON.stringify(value), JSON.stringify(value)]
JavaScriptBridge.eval(script, true)
func _on_native_input_event(arguments: Array) -> void:
if arguments.size() < 3:
return
var event_kind := str(arguments[0])
var input_id := int(arguments[1])
var input := _native_inputs.get(input_id) as Control
if not is_instance_valid(input):
return
var value := str(arguments[2])
if input.text != value:
input.text = value
if input is LineEdit:
(input as LineEdit).set_caret_column(value.length())
match event_kind:
"submit":
if input is LineEdit:
(input as LineEdit).text_submitted.emit(input.text)
"cancel":
_close_native_input()
func _create_focus_style(input: Control) -> StyleBoxFlat:
var focusStyle := StyleBoxFlat.new()
focusStyle.bg_color = Color.TRANSPARENT
focusStyle.border_color = FOCUS_BORDER_COLOR
focusStyle.set_border_width_all(2)
var normalStyle := input.get_theme_stylebox("normal") as StyleBoxFlat
if normalStyle != null:
focusStyle.corner_radius_top_left = normalStyle.corner_radius_top_left
focusStyle.corner_radius_top_right = normalStyle.corner_radius_top_right
focusStyle.corner_radius_bottom_right = normalStyle.corner_radius_bottom_right
focusStyle.corner_radius_bottom_left = normalStyle.corner_radius_bottom_left
else:
focusStyle.set_corner_radius_all(6)
return focusStyle

View File

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

View File

@@ -30,6 +30,9 @@ signal scene_changed(scene_name: String)
# 场景切换开始信号
# 参数: scene_name - 即将切换到的场景名称
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 _next_scene_position: Variant = null # 下一个场景的初始位置 (Vector2 or null)
var _next_spawn_name: String = "" # 下一个场景的出生点名称 (String)
var _pack_manifest: Dictionary = {}
var _loaded_scene_packs: Dictionary = {}
var _loading_scene_packs: Dictionary = {}
var _scene_pack_prefetch_queue: Array[String] = []
var _scene_pack_prefetch_running: bool = false
var _active_pack_request: HTTPRequest
var _active_pack_scene_name: String = ""
var _active_pack_expected_size: int = 0
var _pack_overlay: CanvasLayer
var _pack_status_label: Label
var _pack_progress_bar: ProgressBar
var _web_pack_progress_last_emit_msec: int = 0
const PACK_MANIFEST_PATH: String = "packs/manifest.json"
const PACK_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:
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)
if error != OK:
@@ -112,6 +155,7 @@ func change_scene(scene_name: String, use_transition: bool = true):
current_scene_name = scene_name
is_changing_scene = false
scene_changed.emit(scene_name)
_schedule_scene_prefetch(scene_name)
# 隐藏过渡效果
if use_transition:
@@ -119,6 +163,272 @@ func change_scene(scene_name: String, use_transition: bool = 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)]
# ============ 查询方法 ============
# 获取当前场景名称

View 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)

View File

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

View File

@@ -69,7 +69,7 @@ enum ConnectionState {
# ============================================================================
# WebSocket 服务器 URL原生 WebSocket
const WEBSOCKET_URL: String = "wss://whaletownend.xinghangee.icu/game"
const WEBSOCKET_URL: String = "wss://whaletown.novamailio.com/game"
# 默认最大重连次数
const DEFAULT_MAX_RECONNECT_ATTEMPTS: int = 5
@@ -119,6 +119,9 @@ var _heartbeat_timer: Timer
# 心跳间隔(秒)
const HEARTBEAT_INTERVAL: float = 30.0
const HEARTBEAT_TIMEOUT: float = 75.0
var _last_server_message_at_msec: int = 0
# ============================================================================
# 生命周期方法
@@ -149,6 +152,7 @@ func _process(_delta: float) -> void:
while _websocket_peer.get_available_packet_count() > 0:
var packet: PackedByteArray = _websocket_peer.get_packet()
var message: String = packet.get_string_from_utf8()
_last_server_message_at_msec = Time.get_ticks_msec()
# 发射消息接收信号
data_received.emit(message)
@@ -194,9 +198,6 @@ func connect_to_game_server(is_reconnect_attempt: bool = false) -> void:
_set_connection_state(ConnectionState.ERROR)
return
# 启动心跳
_start_heartbeat()
# 断开 WebSocket 连接
func disconnect_websocket() -> void:
_disconnect()
@@ -358,9 +359,12 @@ func _on_websocket_connected() -> void:
reconnection_succeeded.emit()
_set_connection_state(ConnectionState.CONNECTED)
_last_server_message_at_msec = Time.get_ticks_msec()
_start_heartbeat()
# WebSocket 连接关闭处理
func _on_websocket_closed() -> void:
_stop_heartbeat()
if not _manual_disconnect_requested and _auto_reconnect_enabled:
connection_lost.emit()
_attempt_reconnect()
@@ -382,13 +386,6 @@ func _setup_reconnect_timer() -> void:
# 尝试重连
func _attempt_reconnect() -> void:
# 检查是否超过最大重连次数
if _reconnect_attempt >= _max_reconnect_attempts:
push_error("WebSocketManager: 达到最大重连次数 (%d),停止重连" % _max_reconnect_attempts)
reconnection_failed.emit(_reconnect_attempt, _max_reconnect_attempts)
_set_connection_state(ConnectionState.ERROR)
return
_reconnect_attempt += 1
_set_connection_state(ConnectionState.RECONNECTING)
@@ -401,7 +398,8 @@ func _attempt_reconnect() -> void:
# 计算重连延迟(指数退避)
func _calculate_reconnect_delay() -> float:
# 指数退避: base_delay * 2^(attempt-1)
var delay: float = _reconnect_base_delay * pow(2.0, _reconnect_attempt - 1)
var cappedAttempt := mini(_reconnect_attempt, maxi(_max_reconnect_attempts, 1))
var delay: float = _reconnect_base_delay * pow(2.0, cappedAttempt - 1)
# 限制最大延迟
return min(delay, MAX_RECONNECT_DELAY)
@@ -434,9 +432,16 @@ func _stop_heartbeat() -> void:
# 心跳超时处理
func _on_heartbeat() -> void:
# 不发送心跳,避免服务器返回 "消息格式错误"
# 如果需要心跳,服务器应该支持特定格式
pass
if _websocket_peer.get_ready_state() != WebSocketPeer.STATE_OPEN:
return
var now := Time.get_ticks_msec()
if _last_server_message_at_msec > 0 and now - _last_server_message_at_msec > int(HEARTBEAT_TIMEOUT * 1000.0):
push_warning("WebSocketManager: 心跳超时,正在重新连接")
_websocket_peer.close(4000, "Heartbeat timeout")
return
var err := _websocket_peer.send_text(JSON.stringify({"type": "ping"}))
if err != OK:
push_warning("WebSocketManager: 心跳发送失败 - %s" % error_string(err))
# ============================================================================
# 工具方法
@@ -455,7 +460,7 @@ func get_state_description() -> String:
ConnectionState.CONNECTED:
return "已连接"
ConnectionState.RECONNECTING:
return "重连中 (%d/%d)" % [_reconnect_attempt, _max_reconnect_attempts]
return "重连中 (%d)" % _reconnect_attempt
ConnectionState.ERROR:
return "错误"
_:

View File

@@ -17,8 +17,8 @@ const CONFIG_PATHS: Array[String] = [
]
const DEFAULT_PROFILE: String = "production"
const DEFAULT_API_BASE_URL: String = "https://whaletownend.xinghangee.icu"
const DEFAULT_WS_URL: String = "wss://whaletownend.xinghangee.icu/game"
const DEFAULT_API_BASE_URL: String = "https://whaletown.novamailio.com/api"
const DEFAULT_WS_URL: String = "wss://whaletown.novamailio.com/game"
const API_BASE_URL_ENV_KEY: String = "WHALETOWN_API_BASE_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():
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 url: String = str(network_config.get("api_base_url", "")).strip_edges()
if not url.is_empty():
@@ -51,6 +55,10 @@ static func get_chat_ws_url() -> String:
if not env_url.is_empty():
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 url: String = str(network_config.get("chat_ws_url", network_config.get("game_ws_url", ""))).strip_edges()
if not url.is_empty():
@@ -75,6 +83,10 @@ static func get_location_ws_url() -> String:
if not game_env_url.is_empty():
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 url: String = str(network_config.get("location_ws_url", network_config.get("game_ws_url", ""))).strip_edges()
if not url.is_empty():
@@ -154,7 +166,7 @@ static func _load_config() -> Dictionary:
return {}
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 ""
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)
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:
var value := _get_browser_query_value(key)
if value.is_empty():

View 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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dku0dqqhtnjbk"
path="res://.godot/imported/horned_creature_npc_idle_down.png-fae6e26bf555f707761f7ec796a16f56.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_down.png"
dest_files=["res://.godot/imported/horned_creature_npc_idle_down.png-fae6e26bf555f707761f7ec796a16f56.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bgwcow6lkrb6c"
path="res://.godot/imported/horned_creature_npc_idle_left.png-80f388f72940bd5985d973d135215c97.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_left.png"
dest_files=["res://.godot/imported/horned_creature_npc_idle_left.png-80f388f72940bd5985d973d135215c97.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cmf8t7qsogtkv"
path="res://.godot/imported/horned_creature_npc_idle_right.png-98efb444bb786263b2f3dd1d1241be34.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_right.png"
dest_files=["res://.godot/imported/horned_creature_npc_idle_right.png-98efb444bb786263b2f3dd1d1241be34.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bv70hix5m85sf"
path="res://.godot/imported/horned_creature_npc_idle_up.png-aa780407ff47d0ba8647dab1272956e4.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_up.png"
dest_files=["res://.godot/imported/horned_creature_npc_idle_up.png-aa780407ff47d0ba8647dab1272956e4.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b8ogjmo7jx5x5"
path="res://.godot/imported/horned_creature_npc_spritesheet.png-443e96d87eae79edde2818f42c4ec378.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/characters/generated/horned_creature_npc/horned_creature_npc_spritesheet.png"
dest_files=["res://.godot/imported/horned_creature_npc_spritesheet.png-443e96d87eae79edde2818f42c4ec378.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://i2qjcms43oc0"
path="res://.godot/imported/whale_researcher_no_feet_spritesheet.png-828a36866400f9adbedeaaedec6e718f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/characters/generated/whale_researcher_v2/final_no_feet/processed/whale_researcher_no_feet_spritesheet.png"
dest_files=["res://.godot/imported/whale_researcher_no_feet_spritesheet.png-828a36866400f9adbedeaaedec6e718f.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 KiB

After

Width:  |  Height:  |  Size: 576 KiB

View File

@@ -0,0 +1,96 @@
Fusion Pixel Font
https://github.com/TakWolf/fusion-pixel-font
Copyright (c) 2022, TakWolf (https://takwolf.com).
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://dss1envknmwvy"
path="res://.godot/imported/fusion-pixel-12px-proportional-latin.ttf.woff2-d7baa0634e626d804d8eea7c4b5e5637.fontdata"
[deps]
source_file="res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2"
dest_files=["res://.godot/imported/fusion-pixel-12px-proportional-latin.ttf.woff2-d7baa0634e626d804d8eea7c4b5e5637.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

View File

@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://cea60eanpeue4"
path="res://.godot/imported/fusion-pixel-12px-proportional-zh_hans.ttf.woff2-3005ce2bd956afbf78a39ebd1102f9dc.fontdata"
[deps]
source_file="res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2"
dest_files=["res://.godot/imported/fusion-pixel-12px-proportional-zh_hans.ttf.woff2-3005ce2bd956afbf78a39ebd1102f9dc.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

BIN
assets/fonts/msyh-web.ttf Normal file

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

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

View File

@@ -0,0 +1,15 @@
shader_type canvas_item;
uniform float corner_radius = 0.22;
uniform float edge_feather = 0.008;
void fragment() {
vec2 q = abs(UV - vec2(0.5)) - (vec2(0.5) - vec2(corner_radius));
float outside_distance = length(max(q, vec2(0.0)));
float inside_distance = min(max(q.x, q.y), 0.0);
float signed_distance = outside_distance + inside_distance - corner_radius;
float mask = 1.0 - smoothstep(0.0, edge_feather, signed_distance);
COLOR = texture(TEXTURE, UV);
COLOR.a *= mask;
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://eq2fin4oby2k"
path="res://.godot/imported/empty_whale.png-ffea64aa04d74a02a6935a88182cee3c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/world_bulletin/community/empty_whale.png"
dest_files=["res://.godot/imported/empty_whale.png-ffea64aa04d74a02a6935a88182cee3c.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=true
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bx5uy1p64o4vo"
path="res://.godot/imported/pinned_note.png-034bf127b63da4f239b070c415812df8.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/world_bulletin/community/pinned_note.png"
dest_files=["res://.godot/imported/pinned_note.png-034bf127b63da4f239b070c415812df8.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=true
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b0w5mutlqnlxw"
path="res://.godot/imported/recruit_whales.png-d87526a4f19c9d60855712b106907786.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/world_bulletin/community/recruit_whales.png"
dest_files=["res://.godot/imported/recruit_whales.png-d87526a4f19c9d60855712b106907786.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=true
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bk6gkltoavsd"
path="res://.godot/imported/town_banner.png-ee7512df5a2c66f421bc751ceff6e080.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/world_bulletin/community/town_banner.png"
dest_files=["res://.godot/imported/town_banner.png-ee7512df5a2c66f421bc751ceff6e080.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=true
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://xpkmqb81fjw6"
path="res://.godot/imported/world_bulletin_icon_hd_simple_tight.png-b97116e82ce39ccf8dbc8ba2be158537.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/world_bulletin/community/world_bulletin_icon_hd_simple_tight.png"
dest_files=["res://.godot/imported/world_bulletin_icon_hd_simple_tight.png-b97116e82ce39ccf8dbc8ba2be158537.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=true
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://s42gvnli8h2e"
path="res://.godot/imported/world_bulletin_send_button_final_192.png-8db1899e79e2361bfc869a0da3dc8d51.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/world_bulletin/world_bulletin_send_button_final_192.png"
dest_files=["res://.godot/imported/world_bulletin_send_button_final_192.png-8db1899e79e2361bfc869a0da3dc8d51.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=true
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://2ukykf5vywnj"
path="res://.godot/imported/world_bulletin_send_v2_128.png-e1f7806cd2d6195a5af01a8fcf36310f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ui/world_bulletin/world_bulletin_send_v2_128.png"
dest_files=["res://.godot/imported/world_bulletin_send_v2_128.png-e1f7806cd2d6195a5af01a8fcf36310f.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@@ -1,6 +1,6 @@
[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_local_to_scene = true

24
deploy/Caddyfile.example Normal file
View File

@@ -0,0 +1,24 @@
whaletown.novamailio.com {
root * /var/www/whale-town-front-v2/build/site
@playWithoutSlash path /play
@wasm path *.wasm
@pack path *.pck
redir @playWithoutSlash /play/ 308
handle_path /play/* {
root * /var/www/whale-town-front-v2/build/site/play
header @wasm Content-Type application/wasm
header @pack Content-Type application/octet-stream
file_server
}
handle /downloads/* {
header Content-Disposition "attachment"
file_server
}
handle {
file_server
}
}

129
deploy/Caddyfile.production Normal file
View File

@@ -0,0 +1,129 @@
# Existing Sub2API and blog sites on the Singapore host.
api.novamailio.com {
@long_stream path /v1/responses /responses
handle @long_stream {
reverse_proxy 127.0.0.1:8080 {
flush_interval -1
stream_close_delay 5m
transport http {
keepalive 30s
keepalive_interval 15s
keepalive_idle_conns_per_host 128
}
}
}
handle {
reverse_proxy 127.0.0.1:8080
}
}
xiangking.novamailio.com {
root * /var/www/xiangking-blog
encode zstd gzip
@assets path /assets/*
header @assets Cache-Control "public, max-age=31536000, immutable"
file_server
}
whaletown.novamailio.com {
log {
output file /var/log/caddy/whaletown-access.json {
roll_size 100MiB
roll_keep 10
roll_keep_for 720h
}
format json
}
encode zstd gzip
root * /var/www/whaletown/site
header {
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
}
@immutable_pack {
path /play/packs/*.pck
file {path}
}
header @immutable_pack {
Cache-Control "public, max-age=31536000, immutable"
Content-Type "application/octet-stream"
}
@immutable_admin path /admin/assets/*
header @immutable_admin Cache-Control "public, max-age=31536000, immutable"
@pack_manifest path /play/packs/manifest.json
header @pack_manifest Cache-Control "no-cache"
@runtime path /play/index.js /play/index.wasm /play/index-*.pck /play/index.audio*.js
header @runtime Cache-Control "public, max-age=14400"
@html path / /index.html /play /play/ /play/index.html /admin /admin/ /admin/index.html
header @html Cache-Control "no-cache"
@play_without_slash path /play
redir @play_without_slash /play/ 308
handle_path /play/* {
root * /var/www/whaletown/site/play
file_server
}
@game path /game*
handle @game {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
@location path /location-broadcast
handle @location {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
@notice path /ws/notice
handle @notice {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
@account_assets path /assets/account/*
handle @account_assets {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
redir /api /api/
handle /api/* {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
redir /admin /admin/
handle_path /admin/* {
root * /var/www/whaletown/admin
try_files {path} /index.html
file_server
}
handle /downloads/* {
header Content-Disposition "attachment"
file_server
}
handle {
file_server
}
}

View File

@@ -0,0 +1,89 @@
whaletown.novamailio.com {
log {
output file /var/log/caddy/whaletown-access.json {
roll_size 20MiB
roll_keep 5
roll_keep_for 168h
}
format json
}
encode zstd gzip
root * /var/www/whaletown/site
header {
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
}
@play path /play/*
header @play {
Cross-Origin-Opener-Policy same-origin
Cross-Origin-Embedder-Policy require-corp
}
@immutable_pack {
path /play/packs/*.pck
file {path}
}
header @immutable_pack {
Cache-Control "public, max-age=31536000, immutable"
Content-Type "application/octet-stream"
}
@pack_manifest path /play/packs/manifest.json
header @pack_manifest Cache-Control "no-cache"
@runtime path /play/index.js /play/index.wasm /play/index-*.pck /play/index.audio*.js
header @runtime Cache-Control "public, max-age=14400"
@html path / /index.html /play /play/ /play/index.html
header @html Cache-Control "no-cache"
@play_without_slash path /play
redir @play_without_slash /play/ 308
handle_path /play/* {
root * /var/www/whaletown/site/play
file_server
}
@game path /game*
handle @game {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
@location path /location-broadcast
handle @location {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
@notice path /ws/notice
handle @notice {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
@account_assets path /assets/account/*
handle @account_assets {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
redir /api /api/
handle /api/* {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
handle {
file_server
}
}

View File

@@ -0,0 +1,89 @@
162-251-94-42.sslip.io {
log {
output file /var/log/caddy/whaletown-access.json {
roll_size 20MiB
roll_keep 5
roll_keep_for 168h
}
format json
}
encode zstd gzip
root * /var/www/whaletown/site
header {
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
}
@play path /play/*
header @play {
Cross-Origin-Opener-Policy same-origin
Cross-Origin-Embedder-Policy require-corp
}
@immutable_pack {
path /play/packs/*.pck
file {path}
}
header @immutable_pack {
Cache-Control "public, max-age=31536000, immutable"
Content-Type "application/octet-stream"
}
@pack_manifest path /play/packs/manifest.json
header @pack_manifest Cache-Control "no-cache"
@runtime path /play/index.js /play/index.wasm /play/index-*.pck /play/index.audio*.js
header @runtime Cache-Control "public, max-age=14400"
@html path / /index.html /play /play/ /play/index.html
header @html Cache-Control "no-cache"
@play_without_slash path /play
redir @play_without_slash /play/ 308
handle_path /play/* {
root * /var/www/whaletown/site/play
file_server
}
@game path /game*
handle @game {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
@location path /location-broadcast
handle @location {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
@notice path /ws/notice
handle @notice {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
@account_assets path /assets/account/*
handle @account_assets {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
redir /api /api/
handle /api/* {
reverse_proxy http://124.221.77.216 {
header_up Host 124.221.77.216
}
}
handle {
file_server
}
}

View File

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

View File

@@ -6,8 +6,9 @@ runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
export_filter="scenes"
export_files=PackedStringArray("res://scenes/ui/WebBootstrap.tscn")
include_filter="Config/*.gd,Config/*.json,_Core/*.gd,_Core/managers/*.gd,_Core/systems/*.gd,_Core/utils/*.gd,assets/audio/ui/*.wav,assets/fonts/msyh-web.ttf,assets/shaders/*.gdshader"
exclude_filter=""
export_path="build/web/index.html"
patches=PackedStringArray()
@@ -27,7 +28,7 @@ variant/thread_support=false
vram_texture_compression/for_desktop=true
vram_texture_compression/for_mobile=false
html/export_icon=true
html/custom_html_shell=""
html/custom_html_shell="res://web/progressive_shell.html"
html/head_include=""
html/canvas_resize_policy=2
html/focus_canvas_on_start=true
@@ -52,7 +53,7 @@ dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
exclude_filter="build/**,output/**,docs/**,scripts/**,tools/**"
export_path="build/linux/WhaleTown-V2.x86_64"
patches=PackedStringArray()
encryption_include_filters=""
@@ -72,3 +73,218 @@ texture_format/s3tc_bptc=true
texture_format/etc2_astc=false
architecture/x86_64=true
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"

View File

@@ -14,13 +14,15 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
[application]
config/name="WhaleTown V2"
run/main_scene="res://scenes/ui/AuthScene.tscn"
config/name="WhaleTown"
config/version="1.0.0"
run/main_scene="res://scenes/ui/WebBootstrap.tscn"
config/features=PackedStringArray("4.6", "GL Compatibility")
config/icon="res://icon.svg"
[autoload]
UIFontManager="*res://_Core/managers/UIFontManager.gd"
SceneManager="*res://_Core/managers/SceneManager.gd"
EventSystem="*res://_Core/systems/EventSystem.gd"
ApiClient="*res://_Core/managers/ApiClient.gd"
@@ -32,6 +34,7 @@ CafeCompanionManager="*res://_Core/managers/CafeCompanionManager.gd"
AppearanceManager="*res://_Core/managers/AppearanceManager.gd"
SettingsManager="*res://_Core/managers/SettingsManager.gd"
NotificationSoundManager="*res://_Core/managers/NotificationSoundManager.gd"
InputFocusManager="*res://_Core/managers/InputFocusManager.gd"
[display]
@@ -40,6 +43,10 @@ window/size/viewport_height=1440
window/stretch/mode="canvas_items"
window/stretch/aspect="expand"
[gui]
theme/custom_font="res://assets/fonts/msyh-web.ttf"
[input]
move_left={

View File

@@ -16,11 +16,12 @@ const WORK_ZONE_CAFE_RETURN_POSITION: Vector2 = Vector2(-1093, 560)
const CAFE_DOOR_POSITION: Vector2 = Vector2(0, 392)
const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres")
const COMPANION_NAMEPLATE_RENDER_SCALE: float = 0.5
const COMPANION_NAMEPLATE_FONT_SIZE: int = 24
const COMPANION_NAMEPLATE_VISUAL_HEIGHT: int = 22
const COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH: int = 76
const COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH: int = 118
const COMPANION_NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12
const COMPANION_NAMEPLATE_FONT_SIZE: int = 16
const COMPANION_NAMEPLATE_VISUAL_HEIGHT: int = 15
const COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH: int = 64
const COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH: int = 100
const COMPANION_NAMEPLATE_FONT_ZH: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2")
const COMPANION_NAMEPLATE_FONT_LATIN: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2")
const NPC_NAMEPLATE_OFFSET_Y: float = -136.0
const HIRED_PLAYER_NAMEPLATE_OFFSET_Y: float = -96.0
@@ -36,6 +37,7 @@ const HIRED_PLAYER_NAMEPLATE_OFFSET_Y: float = -96.0
var _isChangingScene: bool = false
var _lastRecruitmentClickMsec: int = 0
var _companionNicknameFont: FontFile
func _ready() -> void:
_align_service_occupants()
@@ -252,34 +254,20 @@ func _configure_companion_nameplate(label: Label, personaName: String, offsetY:
label.clip_text = true
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
label.add_theme_color_override("font_color", Color(0.12, 0.20, 0.24, 1.0))
label.add_theme_color_override("font_shadow_color", Color(1.0, 1.0, 1.0, 0.85))
label.add_theme_constant_override("shadow_offset_x", 0)
label.add_theme_constant_override("shadow_offset_y", 1)
label.add_theme_color_override("font_color", Color(0.09, 0.25, 0.31, 1.0))
label.add_theme_color_override("font_outline_color", Color(1.0, 0.965, 0.88, 0.98))
label.add_theme_constant_override("outline_size", 3)
label.add_theme_font_override("font", _get_companion_nickname_font())
label.add_theme_font_size_override("font_size", COMPANION_NAMEPLATE_FONT_SIZE)
label.add_theme_stylebox_override("normal", _create_companion_nameplate_style())
label.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
func _companion_nameplate_visual_width(displayName: String) -> int:
var estimatedWidth := displayName.length() * COMPANION_NAMEPLATE_VISUAL_CHAR_WIDTH + 28
return clampi(estimatedWidth, COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH, COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH)
var measuredWidth := _get_companion_nickname_font().get_string_size(displayName, HORIZONTAL_ALIGNMENT_LEFT, -1, COMPANION_NAMEPLATE_FONT_SIZE).x
var visualWidth := ceili(measuredWidth * COMPANION_NAMEPLATE_RENDER_SCALE + 20.0)
return clampi(visualWidth, COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH, COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH)
func _create_companion_nameplate_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(1.0, 0.988, 0.955, 0.96)
style.border_color = Color(0.18, 0.34, 0.38, 0.92)
style.border_width_left = 2
style.border_width_top = 2
style.border_width_right = 2
style.border_width_bottom = 2
style.corner_radius_top_left = 12
style.corner_radius_top_right = 12
style.corner_radius_bottom_left = 12
style.corner_radius_bottom_right = 12
style.content_margin_left = 12
style.content_margin_top = 4
style.content_margin_right = 12
style.content_margin_bottom = 4
style.shadow_color = Color(0.05, 0.08, 0.09, 0.18)
style.shadow_size = 4
style.shadow_offset = Vector2(0, 2)
return style
func _get_companion_nickname_font() -> FontFile:
if _companionNicknameFont == null:
_companionNicknameFont = COMPANION_NAMEPLATE_FONT_ZH.duplicate() as FontFile
_companionNicknameFont.fallbacks = [COMPANION_NAMEPLATE_FONT_LATIN]
return _companionNicknameFont

View File

@@ -7,6 +7,7 @@ extends Node
# ============================================================================
const REMOTE_PLAYER_SCENE: PackedScene = preload("res://scenes/characters/remote_player.tscn")
const NETWORK_NPC_SCENE: PackedScene = preload("res://scenes/characters/network_npc.tscn")
const CHAT_BUBBLE_SCENE: PackedScene = preload("res://scenes/ui/ChatBubble.tscn")
const DEFAULT_MAP_ID: String = "whale_port"
const POSITION_SEND_INTERVAL: float = 0.12
@@ -19,19 +20,26 @@ const PRIVATE_CHAT_FORWARD_DOT_THRESHOLD: float = 0.25
@export var map_id: String = DEFAULT_MAP_ID
@export var local_player_path: NodePath = NodePath("../Characters/Players/Player")
@export var remote_players_root_path: NodePath = NodePath("../Characters/Players/RemotePlayers")
@export var network_npcs_root_path: NodePath = NodePath("../Characters/Npcs")
var _local_player: Node2D
var _remote_players_root: Node2D
var _network_npcs_root: Node2D
var _remote_players: Dictionary = {}
var _network_npcs: Dictionary = {}
var _pending_remote_players: Dictionary = {}
var _last_sent_position: Vector2 = Vector2.INF
var _last_sent_at_msec: int = 0
var _last_sent_direction: String = ""
var _last_sent_movement_state: String = ""
var _movement_sequence: int = 0
var _last_private_chat_interaction_msec: int = 0
var _last_friend_request_interaction_msec: int = 0
func _ready() -> void:
_local_player = get_node_or_null(local_player_path) as Node2D
_remote_players_root = get_node_or_null(remote_players_root_path) as Node2D
_network_npcs_root = get_node_or_null(network_npcs_root_path) as Node2D
if _local_player == null:
push_warning("MapMultiplayerController: local player not found.")
@@ -46,6 +54,9 @@ func _ready() -> void:
eventSystem.call("connect_event", EventNames.PLAYER_MOVED, _on_local_player_moved, self)
eventSystem.call("connect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_chat_login_success, self)
eventSystem.call("connect_event", EventNames.REMOTE_PLAYERS_SNAPSHOT_READY, _on_remote_players_snapshot_ready, self)
eventSystem.call("connect_event", EventNames.NPC_SNAPSHOT_READY, _on_npc_snapshot_ready, self)
eventSystem.call("connect_event", EventNames.NPC_ACTION_STARTED, _on_npc_action_started, self)
eventSystem.call("connect_event", EventNames.NPC_ACTION_COMPLETED, _on_npc_action_completed, self)
eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_POSITION_UPDATED, _on_remote_player_position_updated, self)
eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_JOINED, _on_remote_player_joined, self)
eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_LEFT, _on_remote_player_left, self)
@@ -78,6 +89,9 @@ func _exit_tree() -> void:
eventSystem.call("disconnect_event", EventNames.PLAYER_MOVED, _on_local_player_moved, self)
eventSystem.call("disconnect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_chat_login_success, self)
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYERS_SNAPSHOT_READY, _on_remote_players_snapshot_ready, self)
eventSystem.call("disconnect_event", EventNames.NPC_SNAPSHOT_READY, _on_npc_snapshot_ready, self)
eventSystem.call("disconnect_event", EventNames.NPC_ACTION_STARTED, _on_npc_action_started, self)
eventSystem.call("disconnect_event", EventNames.NPC_ACTION_COMPLETED, _on_npc_action_completed, self)
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_POSITION_UPDATED, _on_remote_player_position_updated, self)
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_JOINED, _on_remote_player_joined, self)
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_LEFT, _on_remote_player_left, self)
@@ -175,7 +189,9 @@ func _on_chat_login_success(_data: Dictionary) -> void:
func _on_local_player_moved(data: Dictionary) -> void:
var position_variant: Variant = data.get("position", null)
if position_variant is Vector2:
_send_position(position_variant as Vector2)
var direction := str(data.get("direction", _get_local_direction()))
var movementState := str(data.get("movement_state", "walk"))
_send_position(position_variant as Vector2, direction, movementState)
func _send_world_ready() -> void:
if _local_player == null:
@@ -183,13 +199,16 @@ func _send_world_ready() -> void:
var chatManager := _get_chat_manager()
if chatManager == null or not chatManager.has_method("mark_world_ready"):
return
chatManager.call("mark_world_ready", _get_map_id(), _local_player.global_position)
chatManager.call("mark_world_ready", _get_map_id(), _local_player.global_position, _get_local_direction(), "idle", _movement_sequence)
func _send_position(position: Vector2, force: bool = false) -> void:
func _send_position(position: Vector2, direction: String, movementState: String, force: bool = false) -> void:
var normalizedDirection := _normalize_direction(direction)
var normalizedMovementState := _normalize_movement_state(movementState)
var stateChanged := normalizedDirection != _last_sent_direction or normalizedMovementState != _last_sent_movement_state
var now := Time.get_ticks_msec()
if not force and now - _last_sent_at_msec < int(POSITION_SEND_INTERVAL * 1000.0):
if not force and not stateChanged and now - _last_sent_at_msec < int(POSITION_SEND_INTERVAL * 1000.0):
return
if not force and _last_sent_position != Vector2.INF and _last_sent_position.distance_to(position) < MIN_POSITION_DELTA:
if not force and not stateChanged and _last_sent_position != Vector2.INF and _last_sent_position.distance_to(position) < MIN_POSITION_DELTA:
return
var chatManager := _get_chat_manager()
@@ -200,7 +219,10 @@ func _send_position(position: Vector2, force: bool = false) -> void:
_last_sent_position = position
_last_sent_at_msec = now
chatManager.call("update_player_position", position.x, position.y, _get_map_id())
_last_sent_direction = normalizedDirection
_last_sent_movement_state = normalizedMovementState
_movement_sequence += 1
chatManager.call("update_player_position", position.x, position.y, _get_map_id(), normalizedDirection, normalizedMovementState, _movement_sequence)
func _on_remote_player_joined(data: Dictionary) -> void:
if not _is_event_for_current_map(data):
@@ -210,8 +232,8 @@ func _on_remote_player_joined(data: Dictionary) -> void:
return
var remote_player := _ensure_remote_player(user_id, data)
var position_variant: Variant = data.get("position", null)
if remote_player != null and position_variant is Vector2 and remote_player.has_method("update_position"):
remote_player.call("update_position", position_variant as Vector2)
if remote_player != null and position_variant is Vector2:
_update_remote_player_position(remote_player, position_variant as Vector2, data)
func _on_remote_players_snapshot_ready(data: Dictionary) -> void:
if not _is_event_for_current_map(data):
@@ -230,8 +252,8 @@ func _on_remote_players_snapshot_ready(data: Dictionary) -> void:
seen_user_ids[user_id] = true
var remote_player := _ensure_remote_player(user_id, player_data)
var position_variant: Variant = player_data.get("position", null)
if remote_player != null and position_variant is Vector2 and remote_player.has_method("update_position"):
remote_player.call("update_position", position_variant as Vector2)
if remote_player != null and position_variant is Vector2:
_update_remote_player_position(remote_player, position_variant as Vector2, player_data)
for user_id_variant in _remote_players.keys().duplicate():
var user_id := str(user_id_variant)
@@ -246,6 +268,70 @@ func _on_remote_players_snapshot_ready(data: Dictionary) -> void:
if not seen_user_ids.has(pendingUserId):
_pending_remote_players.erase(pendingUserId)
func _on_npc_snapshot_ready(data: Dictionary) -> void:
if not _is_event_for_current_map(data):
return
if _network_npcs_root == null:
return
var seenNpcIds: Dictionary = {}
var npcsVariant: Variant = data.get("npcs", [])
if npcsVariant is Array:
for npcVariant in npcsVariant:
if not (npcVariant is Dictionary):
continue
var npcData: Dictionary = npcVariant
var npcId := str(npcData.get("npc_id", npcData.get("npcId", ""))).strip_edges()
if npcId.is_empty():
continue
seenNpcIds[npcId] = true
var networkNpc := _ensure_network_npc(npcId)
if networkNpc != null and networkNpc.has_method("apply_snapshot"):
networkNpc.call("apply_snapshot", npcData)
for npcIdVariant in _network_npcs.keys().duplicate():
var npcId := str(npcIdVariant)
if seenNpcIds.has(npcId):
continue
var networkNpc := _network_npcs.get(npcId) as Node
_network_npcs.erase(npcId)
if is_instance_valid(networkNpc):
networkNpc.queue_free()
func _ensure_network_npc(npcId: String) -> Node2D:
if _network_npcs.has(npcId):
var existingNpc := _network_npcs.get(npcId) as Node2D
if is_instance_valid(existingNpc):
return existingNpc
_network_npcs.erase(npcId)
var networkNpc := NETWORK_NPC_SCENE.instantiate() as Node2D
if networkNpc == null:
return null
networkNpc.name = "NetworkNpc_%s" % npcId
networkNpc.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
_network_npcs_root.add_child(networkNpc)
_network_npcs[npcId] = networkNpc
return networkNpc
func _on_npc_action_started(data: Dictionary) -> void:
_apply_npc_action(data, "apply_action_started")
func _on_npc_action_completed(data: Dictionary) -> void:
_apply_npc_action(data, "apply_action_completed")
func _apply_npc_action(data: Dictionary, methodName: String) -> void:
if not _is_event_for_current_map(data):
return
var npcId := str(data.get("npc_id", data.get("npcId", ""))).strip_edges()
if npcId.is_empty():
return
var networkNpc := _network_npcs.get(npcId) as Node
if networkNpc == null:
networkNpc = _ensure_network_npc(npcId)
if networkNpc != null and networkNpc.has_method(methodName):
networkNpc.call(methodName, data)
func _on_remote_player_position_updated(data: Dictionary) -> void:
if not _is_event_for_current_map(data):
return
@@ -260,8 +346,8 @@ func _on_remote_player_position_updated(data: Dictionary) -> void:
return
var remote_player := _ensure_remote_player(user_id, data)
if remote_player != null and remote_player.has_method("update_position"):
remote_player.call("update_position", position_variant as Vector2)
if remote_player != null:
_update_remote_player_position(remote_player, position_variant as Vector2, data)
func _on_remote_player_left(data: Dictionary) -> void:
if not _is_event_for_current_map(data):
@@ -357,8 +443,8 @@ func _on_remote_skin_ready(data: Dictionary) -> void:
_pending_remote_players.erase(userId)
var remotePlayer := _ensure_remote_player(userId, pendingData)
var positionVariant: Variant = pendingData.get("position", null)
if remotePlayer != null and positionVariant is Vector2 and remotePlayer.has_method("update_position"):
remotePlayer.call("update_position", positionVariant as Vector2)
if remotePlayer != null and positionVariant is Vector2:
_update_remote_player_position(remotePlayer, positionVariant as Vector2, pendingData)
func _on_remote_skin_failed(data: Dictionary) -> void:
var skinId := str(data.get("skin_id", "")).strip_edges()
@@ -372,14 +458,14 @@ func _on_remote_skin_failed(data: Dictionary) -> void:
continue
_pending_remote_players.erase(userId)
var fallbackData := pendingData.duplicate(true)
fallbackData["skin_id"] = ""
fallbackData["skinId"] = ""
fallbackData["skin_id"] = "classic_whale"
fallbackData["skinId"] = "classic_whale"
fallbackData.erase("skin_asset")
fallbackData.erase("skinAsset")
var remotePlayer := _ensure_remote_player(userId, fallbackData)
var positionVariant: Variant = pendingData.get("position", null)
if remotePlayer != null and positionVariant is Vector2 and remotePlayer.has_method("update_position"):
remotePlayer.call("update_position", positionVariant as Vector2)
if remotePlayer != null and positionVariant is Vector2:
_update_remote_player_position(remotePlayer, positionVariant as Vector2, pendingData)
func _find_private_chat_target() -> Node2D:
if _local_player == null or _remote_players.is_empty():
@@ -436,9 +522,31 @@ func _apply_remote_player_metadata(remote_player: Node2D, data: Dictionary) -> v
var username := str(data.get("username", "")).strip_edges()
if not username.is_empty():
remote_player.set("username", username)
if data.has("skin_id") or data.has("skinId") or data.has("skin_asset") or data.has("skinAsset") or data.has("avatar_id") or data.has("avatarId") or data.has("cafe_companion") or data.has("cafeCompanion"):
if remote_player.has_method("setup"):
remote_player.call("setup", data)
if remote_player.has_method("update_metadata"):
remote_player.call("update_metadata", data)
func _update_remote_player_position(remotePlayer: Node2D, position: Vector2, data: Dictionary) -> void:
if not remotePlayer.has_method("update_position"):
return
remotePlayer.call(
"update_position",
position,
str(data.get("direction", "")),
str(data.get("movement_state", data.get("movementState", "walk"))),
int(data.get("sequence", -1))
)
func _get_local_direction() -> String:
if _local_player != null:
return _normalize_direction(str(_local_player.get("lastDirection")))
return "down"
func _normalize_direction(value: String) -> String:
var normalized := value.strip_edges().to_lower()
return normalized if normalized in ["down", "up", "right", "left"] else "down"
func _normalize_movement_state(value: String) -> String:
return "walk" if value.strip_edges().to_lower() == "walk" else "idle"
func _resolve_chat_bubble_target(data: Dictionary) -> Node2D:
var from_user := str(data.get("from_user", data.get("username", ""))).strip_edges()

View File

@@ -20,6 +20,9 @@ const CAMERA_LIMIT_BOTTOM: int = 960
func _ready() -> void:
_apply_spawn_point()
_configure_camera()
var chatManager := get_node_or_null("/root/ChatManager")
if chatManager != null and chatManager.has_method("is_guest_mode") and bool(chatManager.call("is_guest_mode")):
player.set_spectator_mode(true)
func _apply_spawn_point() -> void:
var spawnName: String = SceneManager.get_next_spawn_name()

View File

@@ -1,7 +1,7 @@
[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="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/ui/ChatUI.tscn" id="4_chatui"]
[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]
y_sort_enabled = true
[node name="Npcs" type="Node2D" parent="YSortWorld/Characters"]
y_sort_enabled = true
[node name="CafeWhaleBaristaNpc" parent="YSortWorld/Characters" unique_id=1188569491 instance=ExtResource("8_barista_npc")]
position = Vector2(-472, -291)

View File

@@ -12,9 +12,7 @@
[ext_resource type="Texture2D" uid="uid://c8ubnxut51f0r" path="res://assets/maps/square/v1/props/bottom_entrance_left_task_props_v2_hd_clean.png" id="12_lefttask"]
[ext_resource type="Texture2D" uid="uid://b4bvp0r5hua1k" path="res://assets/maps/square/v1/props/bottom_entrance_right_service_props_v2_hd_clean.png" id="13_rightsvc"]
[ext_resource type="PackedScene" path="res://scenes/characters/player.tscn" id="14_u1t8b"]
[ext_resource type="PackedScene" path="res://scenes/characters/npc.tscn" id="17_qog0y"]
[ext_resource type="Texture2D" uid="uid://c0kgmsaach8wh" path="res://assets/maps/square/v1/props/foliage_v13_concept_broadleaf_single/broadleaf_tree_v13_clean.png" id="19_edt5w"]
[ext_resource type="PackedScene" path="res://scenes/characters/crayfish_npc.tscn" id="20_lemld"]
[ext_resource type="Texture2D" uid="uid://q3vldth6fyvw" path="res://assets/maps/square/v1/props/foliage_v14_concept_evergreen_single/evergreen_tree_v14_clean.png" id="20_rixdf"]
[ext_resource type="Texture2D" uid="uid://cbqj51mpelsr4" path="res://assets/maps/square/v1/props/top_fence_v1/top_fence_v1_clean.png" id="22_jagxe"]
[ext_resource type="Texture2D" uid="uid://csxvgs1v5puvt" path="res://assets/maps/square/v1/props/guild_left_tree_flowers_v1/guild_left_tree_flowers_v1_clean.png" id="24_8hf0h"]
@@ -35,6 +33,7 @@
[ext_resource type="Script" uid="uid://dxupavbgcw3tw" path="res://scenes/Maps/MapMultiplayerController.gd" id="38_multiplayer"]
[ext_resource type="PackedScene" path="res://scenes/ui/PlayerHud.tscn" id="39_playerhud"]
[ext_resource type="PackedScene" path="res://scenes/ui/FriendListPanel.tscn" id="40_friendpanel"]
[ext_resource type="PackedScene" path="res://scenes/ui/WorldBulletinPanel.tscn" id="45_world_bulletin"]
[ext_resource type="PackedScene" path="res://scenes/ui/SettingsPanel.tscn" id="41_settingspanel"]
[ext_resource type="Script" uid="uid://dr6uk7m4fsr4l" path="res://scenes/Maps/ScenePortal.gd" id="42_sceneportal"]
[ext_resource type="Script" uid="uid://biu0igl6133q5" path="res://scenes/Maps/Square.gd" id="43_square"]
@@ -134,6 +133,9 @@ layer = 10
[node name="FriendListPanel" parent="UILayer" unique_id=914934559 instance=ExtResource("40_friendpanel")]
[node name="WorldBulletinPanel" parent="UILayer" instance=ExtResource("45_world_bulletin")]
z_index = 1000
[node name="SettingsPanel" parent="UILayer" unique_id=588068795 instance=ExtResource("41_settingspanel")]
[node name="HDGrassBase" type="Sprite2D" parent="." unique_id=1853750890]
@@ -385,20 +387,6 @@ y_sort_enabled = true
[node name="Npcs" type="Node2D" parent="YSortWorld/Characters" unique_id=1114010880]
y_sort_enabled = true
[node name="GuildReceptionNpc" parent="YSortWorld/Characters/Npcs" unique_id=442345899 instance=ExtResource("17_qog0y")]
texture_filter = 1
position = Vector2(-199, -515)
npcName = "范鲸晶"
dialogue = "欢迎来到 WhaleTown 广场。上方是公会大厅,南边是小镇入口,左边通往码头。"
showNameplate = true
nameplateOffsetY = -72.0
[node name="DockGuideNpc" parent="YSortWorld/Characters/Npcs" unique_id=626857126 instance=ExtResource("20_lemld")]
texture_filter = 1
position = Vector2(-825, 437)
showNameplate = true
nameplateOffsetY = -66.0
[node name="MultiplayerController" type="Node" parent="YSortWorld" unique_id=1810372571]
script = ExtResource("38_multiplayer")

View File

@@ -241,6 +241,9 @@ position = Vector2(0, 921)
y_sort_enabled = true
position = Vector2(0, -1)
[node name="Npcs" type="Node2D" parent="YSortWorld/Characters"]
y_sort_enabled = true
[node name="MultiplayerController" type="Node" parent="YSortWorld" unique_id=1024843959]
script = ExtResource("26_multiplayer")
map_id = "work_zone"

View File

@@ -8,27 +8,27 @@ class_name NPCController
# 主要功能:
# - 播放 NPC 待机动画
# - 响应玩家射线交互
# - 触发聊天气泡与 NPC 对话事件
# - 打开 NPC 对话框并广播 NPC 对话事件
#
# 依赖: EventSystem, EventNames, ChatBubble
# 依赖: EventSystem, EventNames, ChatUI
# 作者: Codex
# 创建时间: 2026-03-10
# ============================================================================
signal interaction_happened(text: String)
const CHAT_BUBBLE_SCENE: PackedScene = preload("res://scenes/ui/ChatBubble.tscn")
const CHAT_BUBBLE_LAYER_NAME: String = "WorldChatBubbleLayer"
const CHAT_BUBBLE_TARGET_OFFSET: Vector2 = Vector2(0, -84)
const NPC_TALKED_EVENT: String = "npc_talked"
const NPC_COLLISION_LAYER: int = 2
const NPC_COLLISION_MASK: int = 1
const WORLD_SORT_Z_OFFSET: int = 2048
const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres")
const NAMEPLATE_RENDER_SCALE: float = 0.5
const NAMEPLATE_FONT_SIZE: int = 24
const NAMEPLATE_VISUAL_HEIGHT: int = 22
const NAMEPLATE_VISUAL_MIN_WIDTH: int = 76
const NAMEPLATE_VISUAL_MAX_WIDTH: int = 118
const NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12
const NAMEPLATE_FONT_SIZE: int = 16
const NAMEPLATE_VISUAL_HEIGHT: int = 15
const NAMEPLATE_VISUAL_MIN_WIDTH: int = 64
const NAMEPLATE_VISUAL_MAX_WIDTH: int = 100
const NAMEPLATE_FONT_ZH: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2")
const NAMEPLATE_FONT_LATIN: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2")
@export var npcName: String = "NPC"
@export_multiline var dialogue: String = "欢迎来到WhaleTown我是镇长范鲸晶"
@@ -38,6 +38,7 @@ const NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12
@onready var animation_player: AnimationPlayer = $AnimationPlayer
var _nameplate: Label
var _nicknameFont: FontFile
func _ready() -> void:
# 播放场景里配置好的待机动画,让不同 NPC 可以复用同一个控制器。
@@ -46,16 +47,18 @@ func _ready() -> void:
_update_nameplate()
_update_world_sort_z()
# 保持 NPC 可被玩家射线与角色碰撞识别
collision_layer = 3
collision_mask = 3
# NPC 单独占用交互层;玩家的物理掩码同时包含地图层和 NPC 层
collision_layer = NPC_COLLISION_LAYER
collision_mask = NPC_COLLISION_MASK
func _physics_process(_delta: float) -> void:
_update_world_sort_z()
# 处理玩家交互,展示气泡并向全局事件系统广播。
# 处理玩家交互,打开统一对话框并向全局事件系统广播。
func interact() -> void:
show_bubble(dialogue)
var chatUi := get_tree().root.find_child("ChatUI", true, false)
if chatUi != null and chatUi.has_method("show_npc_dialogue"):
chatUi.call("show_npc_dialogue", npcName, dialogue)
var eventSystem: Node = get_node_or_null("/root/EventSystem")
if eventSystem != null:
eventSystem.call("emit_event", NPC_TALKED_EVENT, {
@@ -65,31 +68,6 @@ func interact() -> void:
})
interaction_happened.emit(dialogue)
# 在 NPC 头顶生成一次性聊天气泡。
#
# 参数:
# text: String - 要展示的对话内容
func show_bubble(text: String) -> void:
var bubble: Control = CHAT_BUBBLE_SCENE.instantiate() as Control
if bubble == null:
return
var bubbleLayer: CanvasLayer = _get_chat_bubble_layer()
bubbleLayer.add_child(bubble)
if bubble.has_method("set_text"):
bubble.call("set_text", text, self, CHAT_BUBBLE_TARGET_OFFSET)
func _get_chat_bubble_layer() -> CanvasLayer:
var root: Window = get_tree().root
var layer: CanvasLayer = root.get_node_or_null(CHAT_BUBBLE_LAYER_NAME) as CanvasLayer
if layer != null:
return layer
layer = CanvasLayer.new()
layer.name = CHAT_BUBBLE_LAYER_NAME
layer.layer = 20
root.add_child(layer)
return layer
func _update_world_sort_z() -> void:
z_index = WORLD_SORT_Z_OFFSET + int(round(global_position.y))
@@ -123,34 +101,20 @@ func _update_nameplate() -> void:
_nameplate.clip_text = true
_nameplate.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
_nameplate.mouse_filter = Control.MOUSE_FILTER_IGNORE
_nameplate.add_theme_color_override("font_color", Color(0.12, 0.20, 0.24, 1.0))
_nameplate.add_theme_color_override("font_shadow_color", Color(1.0, 1.0, 1.0, 0.85))
_nameplate.add_theme_constant_override("shadow_offset_x", 0)
_nameplate.add_theme_constant_override("shadow_offset_y", 1)
_nameplate.add_theme_color_override("font_color", Color(0.09, 0.25, 0.31, 1.0))
_nameplate.add_theme_color_override("font_outline_color", Color(1.0, 0.965, 0.88, 0.98))
_nameplate.add_theme_constant_override("outline_size", 3)
_nameplate.add_theme_font_override("font", _get_nickname_font())
_nameplate.add_theme_font_size_override("font_size", NAMEPLATE_FONT_SIZE)
_nameplate.add_theme_stylebox_override("normal", _create_nameplate_style())
_nameplate.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
func _nameplate_visual_width(displayName: String) -> int:
var estimatedWidth := displayName.length() * NAMEPLATE_VISUAL_CHAR_WIDTH + 28
return clampi(estimatedWidth, NAMEPLATE_VISUAL_MIN_WIDTH, NAMEPLATE_VISUAL_MAX_WIDTH)
var measuredWidth := _get_nickname_font().get_string_size(displayName, HORIZONTAL_ALIGNMENT_LEFT, -1, NAMEPLATE_FONT_SIZE).x
var visualWidth := ceili(measuredWidth * NAMEPLATE_RENDER_SCALE + 20.0)
return clampi(visualWidth, NAMEPLATE_VISUAL_MIN_WIDTH, NAMEPLATE_VISUAL_MAX_WIDTH)
func _create_nameplate_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(1.0, 0.988, 0.955, 0.96)
style.border_color = Color(0.18, 0.34, 0.38, 0.92)
style.border_width_left = 2
style.border_width_top = 2
style.border_width_right = 2
style.border_width_bottom = 2
style.corner_radius_top_left = 12
style.corner_radius_top_right = 12
style.corner_radius_bottom_left = 12
style.corner_radius_bottom_right = 12
style.content_margin_left = 12
style.content_margin_top = 4
style.content_margin_right = 12
style.content_margin_bottom = 4
style.shadow_color = Color(0.05, 0.08, 0.09, 0.18)
style.shadow_size = 4
style.shadow_offset = Vector2(0, 2)
return style
func _get_nickname_font() -> FontFile:
if _nicknameFont == null:
_nicknameFont = NAMEPLATE_FONT_ZH.duplicate() as FontFile
_nicknameFont.fallbacks = [NAMEPLATE_FONT_LATIN]
return _nicknameFont

View File

@@ -0,0 +1,510 @@
extends NPCController
class_name NetworkNpc
var npcId: String = ""
var stateVersion: int = -1
var worldState: String = "idle"
var publicIntention: String = ""
var dailyGoal: String = ""
var planSource: String = "fallback"
var currentActivity: Dictionary = {}
var movementState: String = "idle"
var activeAction: Dictionary = {}
var _actionStartedLocalMsec: int = 0
var _actionCompletesLocalMsec: int = 0
const DIRECTION_ROWS: Dictionary = {"down": 0, "up": 1, "right": 2, "left": 3}
const WALK_ANIMATION_LENGTH: float = 0.8
const IDLE_ANIMATION_LENGTH: float = 1.2
const ACTIVITY_ANIMATION_LENGTH: float = 1.2
const COLLISION_MOTION_SAMPLE_DISTANCE: float = 8.0
const RESEARCHER_TEXTURE: Texture2D = preload("res://assets/characters/generated/whale_researcher_v2/final_no_feet/processed/whale_researcher_no_feet_spritesheet.png")
const MAYOR_TEXTURE: Texture2D = preload("res://assets/characters/npc_286_241.png")
const CRAYFISH_TEXTURE: Texture2D = preload("res://assets/characters/crayfish_npc_256_256.png")
const NIULAI_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_spritesheet.png")
const NIULAI_IDLE_DOWN_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_down.png")
const NIULAI_IDLE_UP_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_up.png")
const NIULAI_IDLE_RIGHT_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_right.png")
const NIULAI_IDLE_LEFT_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_left.png")
var lastDirection: String = "down"
var visualScene: String = ""
var spriteColumns: int = 8
var _lastBlockedActionId: String = ""
var _baseSpritePosition: Vector2 = Vector2.ZERO
var _niulaiIdleTexture: Texture2D
var _baseSpriteScale: Vector2 = Vector2.ONE
var _baseSpriteRotation: float = 0.0
func _ready() -> void:
super._ready()
var eventSystem := get_node_or_null("/root/EventSystem")
if eventSystem != null:
eventSystem.call("connect_event", EventNames.NPC_CONVERSATION, _on_npc_conversation, self)
if animation_player != null:
animation_player.stop()
var sharedLibrary := animation_player.get_animation_library("")
if sharedLibrary != null:
animation_player.remove_animation_library("")
animation_player.add_animation_library("", sharedLibrary.duplicate(true))
_configure_visual("classic_whale")
func _exit_tree() -> void:
var eventSystem := get_node_or_null("/root/EventSystem")
if eventSystem != null:
eventSystem.call("disconnect_event", EventNames.NPC_CONVERSATION, _on_npc_conversation, self)
func interact() -> void:
# 第一次交互和后续交流都进入同一个居中对话框。
var chatUi := get_tree().root.find_child("ChatUI", true, false)
if chatUi != null and chatUi.has_method("start_npc_whisper"):
chatUi.call("start_npc_whisper", npcId, npcName, dialogue)
func _on_npc_conversation(data: Dictionary) -> void:
# 环境中的 NPC 对话只更新下次交互时的开场白,不再弹出世界气泡。
var linesValue: Variant = data.get("lines", [])
if not (linesValue is Array):
return
for lineValue: Variant in (linesValue as Array):
if not (lineValue is Dictionary):
continue
var line: Dictionary = lineValue
if str(line.get("speaker_npc_id", "")) != npcId:
continue
var text := str(line.get("text", "")).strip_edges()
if not text.is_empty():
dialogue = text
func _process(delta: float) -> void:
if activeAction.is_empty():
return
var actionKind := str(activeAction.get("kind", "walk"))
if actionKind == "transition":
return
var now := Time.get_ticks_msec()
var duration: int = maxi(1, _actionCompletesLocalMsec - _actionStartedLocalMsec)
var progress := clampf(float(now - _actionStartedLocalMsec) / float(duration), 0.0, 1.0)
var from := Vector2(float(activeAction.get("from_x", global_position.x)), float(activeAction.get("from_y", global_position.y)))
var to := Vector2(float(activeAction.get("to_x", global_position.x)), float(activeAction.get("to_y", global_position.y)))
var reachedAuthoritativePosition := _move_to_authoritative_position(from.lerp(to, progress))
_update_world_sort_z()
if progress >= 1.0:
if reachedAuthoritativePosition and global_position.distance_to(to) <= 0.5:
global_position = to
activeAction = {}
movementState = "idle"
_play_idle_animation()
func _play_idle_animation() -> void:
if animation_player != null:
if visualScene == "niulai_ambassador" and has_node("Sprite2D"):
_set_niulai_idle_texture("down")
animation_player.play("idle_down")
else:
animation_player.play("idle")
func _play_activity_animation(activityKind: String = "") -> void:
if animation_player == null:
return
if visualScene == "niulai_ambassador" and has_node("Sprite2D"):
_set_niulai_idle_texture("down")
animation_player.play("idle_down")
return
var normalized := activityKind.strip_edges().to_lower()
var animationPrefix := "activity_work"
if normalized == "socialize" or normalized == "share":
animationPrefix = "activity_talk"
var animationName := "%s_%s" % [animationPrefix, lastDirection]
if animation_player.has_animation(animationName):
animation_player.play(animationName)
else:
_play_idle_animation()
func _set_niulai_idle_texture(direction: String) -> void:
var idleTexture: Texture2D = NIULAI_IDLE_DOWN_TEXTURE
match direction:
"up": idleTexture = NIULAI_IDLE_UP_TEXTURE
"right": idleTexture = NIULAI_IDLE_RIGHT_TEXTURE
"left": idleTexture = NIULAI_IDLE_LEFT_TEXTURE
$Sprite2D.texture = idleTexture
$Sprite2D.hframes = 4
$Sprite2D.vframes = 1
func _configure_visual(sceneKey: String) -> void:
if not has_node("Sprite2D"):
return
var normalized := sceneKey.strip_edges()
if normalized.is_empty():
normalized = "classic_whale"
if visualScene == normalized:
return
visualScene = normalized
var sprite := $Sprite2D as Sprite2D
# Network NPC sheets are authored for crisp 2D rendering. Set the filter on
# the actual Sprite2D as well as the parent, since imported textures may
# otherwise fall back to the renderer's linear sampler on Web.
sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
sprite.texture_repeat = CanvasItem.TEXTURE_REPEAT_DISABLED
match visualScene:
"town_mayor":
sprite.texture = MAYOR_TEXTURE
spriteColumns = 4
sprite.position = Vector2.ZERO
sprite.scale = Vector2(0.72, 0.72)
# The mayor frames have transparent padding above the propeller, so the
# name must follow the visible silhouette instead of the frame boundary.
nameplateOffsetY = -52.0
_set_collision_size(Vector2(48, 24))
"dock_crayfish":
sprite.texture = CRAYFISH_TEXTURE
spriteColumns = 4
sprite.position = Vector2.ZERO
sprite.scale = Vector2(0.65, 0.65)
nameplateOffsetY = -66.0
_set_collision_size(Vector2(44, 22))
"niulai_ambassador":
sprite.texture = NIULAI_TEXTURE
spriteColumns = 4
# The generated sheet includes feet. Align the sprite's feet with the
# network node origin used for sorting and collision.
# Its source frames are 384x256 (larger than the 160x160 sheets used
# by the other NPCs), so use a smaller display scale while preserving
# the same ground anchor and a slightly broader physical footprint.
sprite.position = Vector2(0, -45)
sprite.scale = Vector2(0.4, 0.4)
nameplateOffsetY = -105.0
_set_collision_size(Vector2(52, 24))
_:
sprite.texture = RESEARCHER_TEXTURE
spriteColumns = 8
sprite.position = Vector2(0, -29)
sprite.scale = Vector2(0.5, 0.5)
nameplateOffsetY = -72.0
_set_collision_size(Vector2(48, 24))
_baseSpritePosition = sprite.position
_baseSpriteScale = sprite.scale
_baseSpriteRotation = sprite.rotation
sprite.hframes = spriteColumns
sprite.vframes = 4
_configure_directional_animations()
_play_idle_animation()
_update_nameplate()
func _set_collision_size(value: Vector2) -> void:
var collision := get_node_or_null("CollisionShape2D") as CollisionShape2D
if collision != null and collision.shape is RectangleShape2D:
collision.shape = collision.shape.duplicate()
(collision.shape as RectangleShape2D).size = value
func _direction_row(direction: String) -> int:
if visualScene == "niulai_ambassador":
return int({"down": 0, "up": 1, "right": 2, "left": 3}.get(direction, 0))
if spriteColumns == 4:
return int({"down": 0, "right": 1, "up": 2, "left": 3}.get(direction, 0))
return int(DIRECTION_ROWS.get(direction, 0))
func _configure_directional_animations() -> void:
if animation_player == null or not has_node("Sprite2D"):
return
var sprite := $Sprite2D as Sprite2D
var library := animation_player.get_animation_library("")
if library == null:
library = AnimationLibrary.new()
animation_player.add_animation_library("", library)
if library.has_animation("idle"):
library.remove_animation("idle")
var idle := Animation.new()
idle.resource_name = "idle"
idle.length = IDLE_ANIMATION_LENGTH
idle.loop_mode = Animation.LOOP_LINEAR
var idleTrack := idle.add_track(Animation.TYPE_VALUE)
idle.track_set_path(idleTrack, NodePath("Sprite2D:frame"))
idle.value_track_set_update_mode(idleTrack, Animation.UPDATE_DISCRETE)
var idleFrames: Array[int] = [0, 1, 0, 2, 0]
if visualScene == "niulai_ambassador":
sprite.texture = NIULAI_IDLE_DOWN_TEXTURE
idleFrames = [0, 1, 2, 3, 2, 1, 0]
if spriteColumns >= 8:
idleFrames = [0, 2, 0, 4, 0]
for frameIndex in range(idleFrames.size()):
var frameTime := idle.length * float(frameIndex) / float(idleFrames.size() - 1)
idle.track_insert_key(idleTrack, frameTime, idleFrames[frameIndex])
var idlePositionTrack := idle.add_track(Animation.TYPE_VALUE)
idle.track_set_path(idlePositionTrack, NodePath("Sprite2D:position"))
idle.track_insert_key(idlePositionTrack, 0.0, _baseSpritePosition)
idle.track_insert_key(idlePositionTrack, idle.length * 0.25, _baseSpritePosition + Vector2(0.0, -0.75))
idle.track_insert_key(idlePositionTrack, idle.length * 0.5, _baseSpritePosition)
idle.track_insert_key(idlePositionTrack, idle.length * 0.75, _baseSpritePosition + Vector2(0.0, -0.35))
idle.track_insert_key(idlePositionTrack, idle.length, _baseSpritePosition)
library.add_animation("idle", idle)
if visualScene == "niulai_ambassador":
sprite.texture = NIULAI_TEXTURE
sprite.hframes = 4
sprite.vframes = 4
for direction in DIRECTION_ROWS.keys():
var row := _direction_row(str(direction))
if visualScene == "niulai_ambassador":
var idleTextureForDirection: Texture2D = NIULAI_IDLE_DOWN_TEXTURE
match str(direction):
"up": idleTextureForDirection = NIULAI_IDLE_UP_TEXTURE
"right": idleTextureForDirection = NIULAI_IDLE_RIGHT_TEXTURE
"left": idleTextureForDirection = NIULAI_IDLE_LEFT_TEXTURE
sprite.texture = idleTextureForDirection
sprite.hframes = 4
sprite.vframes = 1
var niulaiIdleName := "idle_%s" % direction
if library.has_animation(niulaiIdleName): library.remove_animation(niulaiIdleName)
var niulaiIdle := Animation.new()
niulaiIdle.resource_name = niulaiIdleName
niulaiIdle.length = IDLE_ANIMATION_LENGTH
niulaiIdle.loop_mode = Animation.LOOP_LINEAR
var niulaiIdleTrack := niulaiIdle.add_track(Animation.TYPE_VALUE)
niulaiIdle.track_set_path(niulaiIdleTrack, NodePath("Sprite2D:frame"))
niulaiIdle.value_track_set_update_mode(niulaiIdleTrack, Animation.UPDATE_DISCRETE)
for niulaiFrameIndex in range(idleFrames.size()):
var niulaiFrameTime := niulaiIdle.length * float(niulaiFrameIndex) / float(idleFrames.size() - 1)
niulaiIdle.track_insert_key(niulaiIdleTrack, niulaiFrameTime, idleFrames[niulaiFrameIndex])
library.add_animation(niulaiIdleName, niulaiIdle)
var niulaiWalkName := "walk_%s" % direction
if library.has_animation(niulaiWalkName): library.remove_animation(niulaiWalkName)
var niulaiWalk := Animation.new()
niulaiWalk.resource_name = niulaiWalkName
niulaiWalk.length = WALK_ANIMATION_LENGTH
niulaiWalk.loop_mode = Animation.LOOP_LINEAR
var niulaiWalkTrack := niulaiWalk.add_track(Animation.TYPE_VALUE)
niulaiWalk.track_set_path(niulaiWalkTrack, NodePath("Sprite2D:frame"))
niulaiWalk.value_track_set_update_mode(niulaiWalkTrack, Animation.UPDATE_DISCRETE)
for column in range(4):
niulaiWalk.track_insert_key(
niulaiWalkTrack,
float(column) * WALK_ANIMATION_LENGTH / 4.0,
row * 4 + column,
)
library.add_animation(niulaiWalkName, niulaiWalk)
for staticPrefix in ["activity_work", "activity_talk"]:
var staticName := "%s_%s" % [staticPrefix, direction]
if library.has_animation(staticName): library.remove_animation(staticName)
var staticAnimation := Animation.new()
staticAnimation.resource_name = staticName
staticAnimation.length = 1.0
staticAnimation.loop_mode = Animation.LOOP_LINEAR
var staticTrack := staticAnimation.add_track(Animation.TYPE_VALUE)
staticAnimation.track_set_path(staticTrack, NodePath("Sprite2D:frame"))
staticAnimation.value_track_set_update_mode(staticTrack, Animation.UPDATE_DISCRETE)
staticAnimation.track_insert_key(staticTrack, 0.0, row * spriteColumns)
library.add_animation(staticName, staticAnimation)
continue
var walkName := "walk_%s" % direction
if library.has_animation(walkName): library.remove_animation(walkName)
var walk := Animation.new()
walk.length = WALK_ANIMATION_LENGTH
walk.loop_mode = Animation.LOOP_LINEAR
var walkTrack := walk.add_track(Animation.TYPE_VALUE)
walk.track_set_path(walkTrack, NodePath("Sprite2D:frame"))
walk.value_track_set_update_mode(walkTrack, Animation.UPDATE_DISCRETE)
for column in range(spriteColumns):
walk.track_insert_key(walkTrack, float(column) * WALK_ANIMATION_LENGTH / float(spriteColumns), row * spriteColumns + column)
library.add_animation(walkName, walk)
# Activity animations deliberately reuse the canonical character frame.
# Only the timing and a tiny pixel-scale body motion change, so every NPC
# keeps the exact same face, outfit, proportions, and palette while working.
for activityPrefix in ["activity_work", "activity_talk"]:
var activityName := "%s_%s" % [activityPrefix, direction]
if library.has_animation(activityName): library.remove_animation(activityName)
var activity := Animation.new()
activity.length = ACTIVITY_ANIMATION_LENGTH
activity.loop_mode = Animation.LOOP_LINEAR
var frameTrack := activity.add_track(Animation.TYPE_VALUE)
activity.track_set_path(frameTrack, NodePath("Sprite2D:frame"))
activity.value_track_set_update_mode(frameTrack, Animation.UPDATE_DISCRETE)
var motionFrames: Array[int] = [0, 1, 0, 2, 0]
if activityPrefix == "activity_talk":
motionFrames = [0, 2, 1, 3, 0]
if spriteColumns >= 8:
motionFrames = [0, 2, 0, 4, 0]
if activityPrefix == "activity_talk":
motionFrames = [0, 4, 2, 6, 0]
for frameIndex in range(motionFrames.size()):
var frameTime := ACTIVITY_ANIMATION_LENGTH * float(frameIndex) / float(motionFrames.size() - 1)
activity.track_insert_key(frameTrack, frameTime, row * spriteColumns + motionFrames[frameIndex])
var positionTrack := activity.add_track(Animation.TYPE_VALUE)
activity.track_set_path(positionTrack, NodePath("Sprite2D:position"))
var bobAmount := 1.0 if activityPrefix == "activity_work" else 1.5
activity.track_insert_key(positionTrack, 0.0, _baseSpritePosition)
activity.track_insert_key(positionTrack, ACTIVITY_ANIMATION_LENGTH * 0.25, _baseSpritePosition + Vector2(0.0, -bobAmount))
activity.track_insert_key(positionTrack, ACTIVITY_ANIMATION_LENGTH * 0.5, _baseSpritePosition)
activity.track_insert_key(positionTrack, ACTIVITY_ANIMATION_LENGTH * 0.75, _baseSpritePosition + Vector2(0.0, -bobAmount * 0.5))
activity.track_insert_key(positionTrack, ACTIVITY_ANIMATION_LENGTH, _baseSpritePosition)
var rotationTrack := activity.add_track(Animation.TYPE_VALUE)
activity.track_set_path(rotationTrack, NodePath("Sprite2D:rotation"))
var tiltAmount := 0.018 if activityPrefix == "activity_work" else 0.028
activity.track_insert_key(rotationTrack, 0.0, _baseSpriteRotation)
activity.track_insert_key(rotationTrack, ACTIVITY_ANIMATION_LENGTH * 0.25, _baseSpriteRotation - tiltAmount)
activity.track_insert_key(rotationTrack, ACTIVITY_ANIMATION_LENGTH * 0.5, _baseSpriteRotation)
activity.track_insert_key(rotationTrack, ACTIVITY_ANIMATION_LENGTH * 0.75, _baseSpriteRotation + tiltAmount * 0.6)
activity.track_insert_key(rotationTrack, ACTIVITY_ANIMATION_LENGTH, _baseSpriteRotation)
var scaleTrack := activity.add_track(Animation.TYPE_VALUE)
activity.track_set_path(scaleTrack, NodePath("Sprite2D:scale"))
var scaleAmount := 0.012 if activityPrefix == "activity_work" else 0.018
activity.track_insert_key(scaleTrack, 0.0, _baseSpriteScale)
activity.track_insert_key(scaleTrack, ACTIVITY_ANIMATION_LENGTH * 0.25, _baseSpriteScale * Vector2(1.0, 1.0 + scaleAmount))
activity.track_insert_key(scaleTrack, ACTIVITY_ANIMATION_LENGTH * 0.5, _baseSpriteScale)
activity.track_insert_key(scaleTrack, ACTIVITY_ANIMATION_LENGTH * 0.75, _baseSpriteScale * Vector2(1.0, 1.0 + scaleAmount * 0.5))
activity.track_insert_key(scaleTrack, ACTIVITY_ANIMATION_LENGTH, _baseSpriteScale)
library.add_animation(activityName, activity)
func apply_snapshot(data: Dictionary) -> void:
var incomingVersion := int(data.get("version", 0))
if stateVersion > incomingVersion:
return
var isInitialSnapshot := stateVersion < 0
npcId = str(data.get("npc_id", data.get("npcId", npcId))).strip_edges()
_configure_visual(str(data.get("scene", "classic_whale")))
stateVersion = incomingVersion
npcName = str(data.get("name", npcName)).strip_edges()
worldState = str(data.get("state", "idle")).strip_edges()
publicIntention = str(data.get("public_intention", data.get("publicIntention", ""))).strip_edges()
dailyGoal = str(data.get("daily_goal", data.get("dailyGoal", ""))).strip_edges()
planSource = str(data.get("plan_source", data.get("planSource", "fallback"))).strip_edges()
var activityValue: Variant = data.get("current_activity", data.get("currentActivity", {}))
currentActivity = activityValue if activityValue is Dictionary else {}
dialogue = str(data.get("dialogue", _fallback_dialogue())).strip_edges()
movementState = str(data.get("movement_state", data.get("movementState", "idle"))).strip_edges()
var snapshotDirection := str(data.get("direction", lastDirection)).strip_edges().to_lower()
if DIRECTION_ROWS.has(snapshotDirection):
lastDirection = snapshotDirection
showNameplate = true
var snapshotPosition := Vector2(float(data.get("x", global_position.x)), float(data.get("y", global_position.y)))
if isInitialSnapshot:
_place_at_clear_position(snapshotPosition)
else:
_move_to_authoritative_position(snapshotPosition)
_update_nameplate()
_update_world_sort_z()
var snapshotAction: Variant = data.get("active_action", data.get("activeAction", {}))
if snapshotAction is Dictionary and not (snapshotAction as Dictionary).is_empty():
_apply_action(snapshotAction, int(data.get("server_now", 0)))
else:
activeAction = {}
if _is_activity_state():
_play_activity_animation(str(currentActivity.get("activityKind", currentActivity.get("activity_kind", ""))))
else:
_play_idle_animation()
func apply_action_started(data: Dictionary) -> void:
_apply_action(data.get("action", {}), int(data.get("server_now", data.get("serverNow", 0))))
func _is_activity_state() -> bool:
var normalizedState := worldState.strip_edges().to_lower()
if normalizedState in ["working", "talking", "performing", "acting", "socializing", "sharing"]:
return not currentActivity.is_empty() or normalizedState != "working"
return false
func apply_action_completed(data: Dictionary) -> void:
var action: Variant = data.get("action", {})
if not (action is Dictionary):
return
var completed: Dictionary = action
var actionKind := str(completed.get("kind", "walk"))
var incomingVersion := int(completed.get("version", 0))
if incomingVersion < stateVersion:
return
stateVersion = incomingVersion
if actionKind == "transition":
visible = false
else:
var completedPosition := Vector2(float(completed.get("to_x", completed.get("toX", global_position.x))), float(completed.get("to_y", completed.get("toY", global_position.y))))
if not _move_to_authoritative_position(completedPosition):
movementState = "idle"
_play_idle_animation()
return
_update_world_sort_z()
activeAction = {}
movementState = "idle"
_play_idle_animation()
func _apply_action(value: Variant, serverNow: int) -> void:
if not (value is Dictionary):
return
var incoming: Dictionary = value
var incomingVersion := int(incoming.get("version", 0))
if incomingVersion < stateVersion:
return
stateVersion = incomingVersion
activeAction = incoming
visible = true
var actionKind := str(incoming.get("kind", "walk"))
movementState = "walk" if actionKind == "walk" else "idle"
worldState = "travelling" if actionKind == "transition" else ("talking" if str(incoming.get("activity_kind", "")) == "socialize" else ("working" if actionKind == "perform" else "walking"))
var dx := float(incoming.get("to_x", incoming.get("toX", 0.0))) - float(incoming.get("from_x", incoming.get("fromX", 0.0)))
var dy := float(incoming.get("to_y", incoming.get("toY", 0.0))) - float(incoming.get("from_y", incoming.get("fromY", 0.0)))
if actionKind == "walk":
if absf(dx) > absf(dy):
lastDirection = "right" if dx >= 0.0 else "left"
else:
lastDirection = "down" if dy >= 0.0 else "up"
if animation_player != null and actionKind == "walk":
if visualScene == "niulai_ambassador" and has_node("Sprite2D"):
$Sprite2D.texture = NIULAI_TEXTURE
$Sprite2D.hframes = 4
$Sprite2D.vframes = 4
animation_player.play("walk_%s" % lastDirection)
elif animation_player != null and actionKind == "perform":
_play_activity_animation(str(incoming.get("activity_kind", incoming.get("activityKind", ""))))
elif animation_player != null:
_play_idle_animation()
var startedAt := int(incoming.get("started_at", incoming.get("startedAt", 0)))
var completesAt := int(incoming.get("completes_at", incoming.get("completesAt", startedAt)))
var offset := Time.get_ticks_msec() - serverNow if serverNow > 0 else 0
_actionStartedLocalMsec = startedAt + offset
_actionCompletesLocalMsec = completesAt + offset
if actionKind != "transition":
var from := Vector2(float(incoming.get("from_x", incoming.get("fromX", global_position.x))), float(incoming.get("from_y", incoming.get("fromY", global_position.y))))
var to := Vector2(float(incoming.get("to_x", incoming.get("toX", from.x))), float(incoming.get("to_y", incoming.get("toY", from.y))))
var duration: int = maxi(1, _actionCompletesLocalMsec - _actionStartedLocalMsec)
var progress := clampf(float(Time.get_ticks_msec() - _actionStartedLocalMsec) / float(duration), 0.0, 1.0)
_move_to_authoritative_position(from.lerp(to, progress))
_update_world_sort_z()
func _move_to_authoritative_position(target: Vector2) -> bool:
# Network NPC positions are authoritative. Static map collisions are already
# resolved by the server's route graph; blocking the replicated position on
# the client makes every NPC freeze when a decorative collider is slightly
# offset from a route point. Keep the shape for interaction, but never reject
# an authoritative movement update locally.
global_position = target
return true
func _place_at_clear_position(target: Vector2) -> bool:
global_position = target
return true
func _has_static_collision_at(target: Vector2) -> bool:
var collision := get_node_or_null("CollisionShape2D") as CollisionShape2D
if collision == null or collision.shape == null or not is_inside_tree():
return false
var query := PhysicsShapeQueryParameters2D.new()
query.shape = collision.shape
var candidateTransform := collision.global_transform
candidateTransform.origin += target - global_position
query.transform = candidateTransform
query.collision_mask = 1
query.collide_with_areas = false
query.collide_with_bodies = true
query.exclude = [get_rid()]
for hit in get_world_2d().direct_space_state.intersect_shape(query, 16):
if hit.get("collider") is StaticBody2D:
return true
return false
func _report_blocked_route(position: Vector2) -> void:
var actionId := str(activeAction.get("action_id", activeAction.get("actionId", "snapshot")))
if actionId == _lastBlockedActionId:
return
_lastBlockedActionId = actionId
push_warning("NetworkNpc route blocked by static collision: npc=%s action=%s position=%s" % [npcId, actionId, position])
func _fallback_dialogue() -> String:
if not publicIntention.is_empty():
return "你好,我是%s%s" % [npcName, publicIntention]
return "你好,我是%s" % npcName

View File

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

View File

@@ -6,10 +6,23 @@ signal player_moved(position: Vector2)
# 常量定义
const MOVE_SPEED = 200.0
const PLAYER_COLLISION_LAYER = 1
const PLAYER_COLLISION_MASK = 3
const WORLD_SORT_Z_OFFSET = 2048
const INTERACTION_COLLISION_MASK = 2
const WALK_ANIMATION_LENGTH = 0.8
const NAME_LABEL_OFFSET: Vector2 = Vector2(-70, -112)
const NAME_LABEL_RENDER_SCALE: float = 0.5
const NAME_LABEL_FONT_SIZE: int = 16
const NAME_LABEL_VISUAL_HEIGHT: int = 15
const NAME_LABEL_VISUAL_MIN_WIDTH: int = 64
const NAME_LABEL_VISUAL_MAX_WIDTH: int = 100
const NAME_LABEL_VISUAL_CHAR_WIDTH: int = 10
const NAME_LABEL_OFFSET_Y: float = -72.0
const NAME_LABEL_SIDE_OFFSET_Y: float = -84.0
const NAME_LABEL_BACK_OFFSET_Y: float = -78.0
const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres")
const NAME_LABEL_FONT_ZH: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2")
const NAME_LABEL_FONT_LATIN: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2")
const DIRECTION_ROWS: Dictionary = {
"down": 0,
"up": 1,
@@ -24,9 +37,32 @@ const DIRECTION_ROWS: Dictionary = {
var lastDirection: String = "down"
var _nameLabel: Label
var _nicknameFont: FontFile
var _cachedNameLabelText: String = ""
var _cachedNameLabelDirection: String = ""
var _cachedNameLabelVisible: bool = false
var _movementLocked: bool = false
var _lastEmittedMovementState: String = "idle"
var _spectator_mode: bool = false
func set_spectator_mode(enabled: bool) -> void:
_spectator_mode = enabled
if is_instance_valid(sprite):
sprite.visible = not enabled
if is_instance_valid(_nameLabel):
_nameLabel.visible = not enabled
if is_instance_valid(ray_cast):
ray_cast.enabled = not enabled
if enabled:
collision_layer = 0
collision_mask = 0
else:
collision_layer = PLAYER_COLLISION_LAYER
collision_mask = PLAYER_COLLISION_MASK
func _ready() -> void:
collision_layer = PLAYER_COLLISION_LAYER
collision_mask = PLAYER_COLLISION_MASK
_reset_movement_input_state()
_apply_current_appearance()
_subscribe_to_appearance_events()
@@ -81,6 +117,8 @@ func _physics_process(delta: float) -> void:
_handle_interaction()
func _handle_interaction() -> void:
if _spectator_mode:
return
if _is_text_input_focused():
return
if Input.is_action_just_pressed("interact"):
@@ -100,6 +138,7 @@ func _handle_movement(_delta: float) -> void:
velocity = Vector2.ZERO
_play_idle_animation()
move_and_slide()
_emit_movement_sync("idle")
return
# 输入框获得焦点时禁止移动,避免聊天/表单输入影响角色
@@ -108,6 +147,7 @@ func _handle_movement(_delta: float) -> void:
velocity = Vector2.ZERO
_play_idle_animation()
move_and_slide()
_emit_movement_sync("idle")
return
# 获取移动向量 (参考 docs/02-开发规范/输入映射配置.md)
@@ -126,17 +166,28 @@ func _handle_movement(_delta: float) -> void:
move_and_slide()
# 发送移动事件 (如果位置发生明显变化)
if velocity.length() > 0:
# 移动中持续发送位置;停止时额外发送一次 idle供远端切回待机动画。
var movementState := "walk" if velocity.length() > 0 else "idle"
_emit_movement_sync(movementState)
func _emit_movement_sync(movementState: String) -> void:
if _spectator_mode:
return
if movementState == "walk":
player_moved.emit(global_position)
if movementState == "walk" or movementState != _lastEmittedMovementState:
EventSystem.emit_event(EventNames.PLAYER_MOVED, {
"position": global_position
"position": global_position,
"direction": lastDirection,
"movement_state": movementState
})
_lastEmittedMovementState = movementState
func _update_animation_state(direction: Vector2) -> void:
if not animation_player:
return
var previousDirection := lastDirection
# Determine primary direction
if abs(direction.x) > abs(direction.y):
if direction.x > 0:
@@ -153,6 +204,8 @@ func _update_animation_state(direction: Vector2) -> void:
lastDirection = "up"
ray_cast.target_position = Vector2(0, -60)
if lastDirection != previousDirection:
_update_name_label()
animation_player.play("walk_" + lastDirection)
func _play_idle_animation() -> void:
@@ -241,19 +294,61 @@ func _create_name_label() -> void:
return
_nameLabel = Label.new()
_nameLabel.name = "NameLabel"
_nameLabel.position = NAME_LABEL_OFFSET
_nameLabel.custom_minimum_size = Vector2(140, 28)
_nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_nameLabel.add_theme_color_override("font_color", Color(0.188, 0.294, 0.424))
_nameLabel.add_theme_font_size_override("font_size", 14)
_nameLabel.add_theme_stylebox_override("normal", _create_name_label_style())
add_child(_nameLabel)
func _update_name_label() -> void:
if not is_instance_valid(_nameLabel):
return
_nameLabel.text = _current_username()
_nameLabel.visible = _settings_bool("show_name_always", false)
var displayName := _current_username()
var showName := _settings_bool("show_name_always", false)
if displayName == _cachedNameLabelText and lastDirection == _cachedNameLabelDirection and showName == _cachedNameLabelVisible:
return
var visualWidth := _name_label_visual_width(displayName)
var renderWidth := ceili(float(visualWidth) / NAME_LABEL_RENDER_SCALE)
var renderHeight := ceili(float(NAME_LABEL_VISUAL_HEIGHT) / NAME_LABEL_RENDER_SCALE)
_nameLabel.theme = WORLD_TEXT_THEME
_nameLabel.text = displayName
_nameLabel.visible = showName
_nameLabel.z_index = 30
_nameLabel.scale = Vector2.ONE * NAME_LABEL_RENDER_SCALE
_nameLabel.position = Vector2(float(visualWidth) * -0.5, _name_label_offset_y())
_nameLabel.custom_minimum_size = Vector2(renderWidth, renderHeight)
_nameLabel.size = _nameLabel.custom_minimum_size
_nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_nameLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_nameLabel.clip_text = true
_nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
_nameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_nameLabel.add_theme_color_override("font_color", Color(0.09, 0.25, 0.31, 1.0))
_nameLabel.add_theme_color_override("font_outline_color", Color(1.0, 0.965, 0.88, 0.98))
_nameLabel.add_theme_constant_override("outline_size", 3)
_nameLabel.add_theme_font_override("font", _get_nickname_font())
_nameLabel.add_theme_font_size_override("font_size", NAME_LABEL_FONT_SIZE)
_nameLabel.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
_cachedNameLabelText = displayName
_cachedNameLabelDirection = lastDirection
_cachedNameLabelVisible = showName
func _name_label_visual_width(displayName: String) -> int:
var measuredWidth := _get_nickname_font().get_string_size(displayName, HORIZONTAL_ALIGNMENT_LEFT, -1, NAME_LABEL_FONT_SIZE).x
var visualWidth := ceili(measuredWidth * NAME_LABEL_RENDER_SCALE + 20.0)
return clampi(visualWidth, NAME_LABEL_VISUAL_MIN_WIDTH, NAME_LABEL_VISUAL_MAX_WIDTH)
func _get_nickname_font() -> FontFile:
if _nicknameFont == null:
_nicknameFont = NAME_LABEL_FONT_ZH.duplicate() as FontFile
_nicknameFont.fallbacks = [NAME_LABEL_FONT_LATIN]
return _nicknameFont
func _name_label_offset_y() -> float:
match lastDirection:
"left", "right":
return NAME_LABEL_SIDE_OFFSET_Y
"up":
return NAME_LABEL_BACK_OFFSET_Y
_:
return NAME_LABEL_OFFSET_Y
func _current_username() -> String:
var authManager := get_node_or_null("/root/AuthManager")
@@ -268,16 +363,3 @@ func _settings_bool(key: String, defaultValue: bool) -> bool:
if settingsManager != null and settingsManager.has_method("get_bool"):
return bool(settingsManager.call("get_bool", key))
return defaultValue
func _create_name_label_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(1.0, 1.0, 1.0, 0.74)
style.corner_radius_top_left = 10
style.corner_radius_top_right = 10
style.corner_radius_bottom_left = 10
style.corner_radius_bottom_right = 10
style.content_margin_left = 8
style.content_margin_right = 8
style.content_margin_top = 4
style.content_margin_bottom = 4
return style

View File

@@ -7,17 +7,35 @@ class_name RemotePlayer
var userId: String = ""
var username: String = ""
var skinId: String = ""
const DEFAULT_REMOTE_SKIN_ID: String = "classic_whale"
var skinId: String = DEFAULT_REMOTE_SKIN_ID
var skinAsset: Dictionary = {}
var avatarId: String = ""
var targetPosition: Vector2 = Vector2.ZERO
var cafeCompanionData: Dictionary = {}
var movementState: String = "idle"
var lastSequence: int = -1
var _hasSyncedDirection: bool = false
var _nicknameFont: FontFile
var _cachedNameLabelText: String = ""
var _cachedNameLabelPersona: String = ""
var _cachedNameLabelDirection: String = ""
var _cachedNameLabelVisible: bool = false
# 内部状态
var lastDirection: String = "down"
const WORLD_SORT_Z_OFFSET = 2048
const WALK_ANIMATION_LENGTH = 0.8
const NAME_LABEL_OFFSET: Vector2 = Vector2(-70, -112)
const NAME_LABEL_RENDER_SCALE: float = 0.5
const NAME_LABEL_FONT_SIZE: int = 16
const NAME_LABEL_VISUAL_HEIGHT: int = 15
const NAME_LABEL_VISUAL_MIN_WIDTH: int = 64
const NAME_LABEL_VISUAL_MAX_WIDTH: int = 100
const NAME_LABEL_VISUAL_CHAR_WIDTH: int = 10
const NAME_LABEL_OFFSET_Y: float = -72.0
const NAME_LABEL_SIDE_OFFSET_Y: float = -84.0
const NAME_LABEL_BACK_OFFSET_Y: float = -78.0
const CAFE_NAME_LABEL_RENDER_SCALE: float = 0.5
const CAFE_NAME_LABEL_FONT_SIZE: int = 24
const CAFE_NAME_LABEL_VISUAL_HEIGHT: int = 22
@@ -26,6 +44,8 @@ const CAFE_NAME_LABEL_VISUAL_MAX_WIDTH: int = 118
const CAFE_NAME_LABEL_VISUAL_CHAR_WIDTH: int = 12
const CAFE_NAME_LABEL_OFFSET_Y: float = -96.0
const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres")
const NAME_LABEL_FONT_ZH: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2")
const NAME_LABEL_FONT_LATIN: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2")
const DIRECTION_ROWS: Dictionary = {
"down": 0,
"up": 1,
@@ -73,37 +93,15 @@ func _process(delta: float) -> void:
global_position = newPos
_update_world_sort_z()
else:
# 距离很近时直接吸附并播放待机动画
# 距离很近时吸附;动画状态由发送端的 idle/walk 决定。
global_position = targetPosition
_update_world_sort_z()
_play_idle_animation()
_play_current_animation()
# 统一初始化方法
# data: 包含 camelCase 字段的字典 (userId, username, position 等)
func setup(data: Dictionary) -> void:
if data.has("userId"):
userId = data.userId
if data.has("username"):
username = str(data.username)
if data.has("skin_id"):
skinId = str(data.get("skin_id", ""))
elif data.has("skinId"):
skinId = str(data.get("skinId", ""))
if data.has("skin_asset"):
var skinAssetData: Variant = data.get("skin_asset", {})
skinAsset = skinAssetData if skinAssetData is Dictionary else {}
elif data.has("skinAsset"):
var skinAssetPayload: Variant = data.get("skinAsset", {})
skinAsset = skinAssetPayload if skinAssetPayload is Dictionary else {}
if data.has("avatar_id"):
avatarId = str(data.get("avatar_id", ""))
elif data.has("avatarId"):
avatarId = str(data.get("avatarId", ""))
if data.has("cafe_companion") or data.has("cafeCompanion"):
cafeCompanionData = _normalize_cafe_companion_data(data.get("cafe_companion", data.get("cafeCompanion", null)))
_apply_appearance()
_configure_cafe_companion_target()
_update_name_label()
update_metadata(data)
if data.has("position"):
var positionData: Variant = data.position
@@ -111,15 +109,73 @@ func setup(data: Dictionary) -> void:
global_position = positionData
targetPosition = positionData
_update_world_sort_z()
elif positionData.has("x") and positionData.has("y"):
elif positionData is Dictionary and positionData.has("x") and positionData.has("y"):
var newPos := Vector2(positionData.x, positionData.y)
global_position = newPos
targetPosition = newPos
_update_world_sort_z()
_apply_movement_state(data)
func update_metadata(data: Dictionary) -> void:
var appearanceChanged := false
var companionChanged := false
if data.has("userId"):
userId = data.userId
if data.has("username"):
username = str(data.username)
if data.has("skin_id"):
var nextSkinId := _normalize_remote_skin_id(str(data.get("skin_id", "")))
appearanceChanged = appearanceChanged or nextSkinId != skinId
skinId = nextSkinId
elif data.has("skinId"):
var nextSkinId := _normalize_remote_skin_id(str(data.get("skinId", "")))
appearanceChanged = appearanceChanged or nextSkinId != skinId
skinId = nextSkinId
if data.has("skin_asset"):
var skinAssetData: Variant = data.get("skin_asset", {})
var nextSkinAsset: Dictionary = skinAssetData if skinAssetData is Dictionary else {}
appearanceChanged = appearanceChanged or nextSkinAsset != skinAsset
skinAsset = nextSkinAsset
elif data.has("skinAsset"):
var skinAssetPayload: Variant = data.get("skinAsset", {})
var nextSkinAsset: Dictionary = skinAssetPayload if skinAssetPayload is Dictionary else {}
appearanceChanged = appearanceChanged or nextSkinAsset != skinAsset
skinAsset = nextSkinAsset
if data.has("avatar_id"):
avatarId = str(data.get("avatar_id", ""))
elif data.has("avatarId"):
avatarId = str(data.get("avatarId", ""))
if data.has("cafe_companion") or data.has("cafeCompanion"):
var nextCompanionData := _normalize_cafe_companion_data(data.get("cafe_companion", data.get("cafeCompanion", null)))
companionChanged = nextCompanionData != cafeCompanionData
cafeCompanionData = nextCompanionData
if appearanceChanged:
_apply_appearance()
if companionChanged:
_configure_cafe_companion_target()
_update_name_label()
func _normalize_remote_skin_id(value: String) -> String:
var normalized := value.strip_edges()
if normalized.is_empty() or normalized == "pending_initial_skin":
return DEFAULT_REMOTE_SKIN_ID
return normalized
# 更新目标位置
func update_position(newPos: Vector2) -> void:
func update_position(newPos: Vector2, direction: String = "", nextMovementState: String = "walk", sequence: int = -1) -> void:
if sequence >= 0 and lastSequence >= 0 and sequence <= lastSequence:
return
if sequence >= 0:
lastSequence = sequence
var normalizedDirection := _normalize_direction(direction)
if not normalizedDirection.is_empty():
lastDirection = normalizedDirection
_hasSyncedDirection = true
_update_name_label()
movementState = "walk" if nextMovementState.strip_edges().to_lower() == "walk" else "idle"
targetPosition = newPos
if global_position.distance_to(targetPosition) <= 1.0:
_play_current_animation()
func _update_world_sort_z() -> void:
z_index = WORLD_SORT_Z_OFFSET + int(round(global_position.y))
@@ -128,7 +184,8 @@ func _update_animation(moveVec: Vector2) -> void:
if not animation_player:
return
# 确定主方向
# 新协议使用发送端方向;兼容旧位置包时再从位移向量推断。
if not _hasSyncedDirection:
if abs(moveVec.x) > abs(moveVec.y):
if moveVec.x > 0:
lastDirection = "right"
@@ -142,6 +199,28 @@ func _update_animation(moveVec: Vector2) -> void:
animation_player.play("walk_" + lastDirection)
func _apply_movement_state(data: Dictionary) -> void:
var normalizedDirection := _normalize_direction(str(data.get("direction", "")))
if not normalizedDirection.is_empty():
lastDirection = normalizedDirection
_hasSyncedDirection = true
_update_name_label()
movementState = "walk" if str(data.get("movement_state", data.get("movementState", "idle"))).strip_edges().to_lower() == "walk" else "idle"
lastSequence = int(data.get("sequence", lastSequence))
_play_current_animation()
func _normalize_direction(value: String) -> String:
var normalized := value.strip_edges().to_lower()
return normalized if normalized in ["down", "up", "right", "left"] else ""
func _play_current_animation() -> void:
if animation_player == null:
return
if movementState == "walk":
animation_player.play("walk_" + lastDirection)
else:
_play_idle_animation()
func _play_idle_animation() -> void:
if animation_player:
animation_player.play("idle_" + lastDirection)
@@ -206,25 +285,26 @@ func _create_name_label() -> void:
return
_nameLabel = Label.new()
_nameLabel.name = "NameLabel"
_nameLabel.position = NAME_LABEL_OFFSET
_nameLabel.custom_minimum_size = Vector2(140, 28)
_nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_nameLabel.add_theme_color_override("font_color", Color(0.188, 0.294, 0.424))
_nameLabel.add_theme_font_size_override("font_size", 14)
_nameLabel.add_theme_stylebox_override("normal", _create_name_label_style())
add_child(_nameLabel)
func _update_name_label() -> void:
if not is_instance_valid(_nameLabel):
return
var personaName := str(cafeCompanionData.get("persona_name", "")).strip_edges()
var displayName := personaName if not personaName.is_empty() else (username if not username.strip_edges().is_empty() else "玩家")
var showName := true if not personaName.is_empty() else (_is_guest_mode() or _settings_bool("show_name_always", false))
if displayName == _cachedNameLabelText and personaName == _cachedNameLabelPersona and lastDirection == _cachedNameLabelDirection and showName == _cachedNameLabelVisible:
return
if not personaName.is_empty():
_configure_cafe_name_label(personaName)
_nameLabel.visible = true
return
else:
_configure_default_name_label()
_nameLabel.text = username if not username.strip_edges().is_empty() else "玩家"
_nameLabel.visible = _settings_bool("show_name_always", false)
_nameLabel.text = displayName
_nameLabel.visible = showName
_cachedNameLabelText = displayName
_cachedNameLabelPersona = personaName
_cachedNameLabelDirection = lastDirection
_cachedNameLabelVisible = showName
func _configure_cafe_name_label(personaName: String) -> void:
var displayName := personaName.strip_edges()
@@ -246,30 +326,61 @@ func _configure_cafe_name_label(personaName: String) -> void:
_nameLabel.clip_text = true
_nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
_nameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_nameLabel.add_theme_color_override("font_color", Color(0.12, 0.20, 0.24, 1.0))
_nameLabel.add_theme_color_override("font_color", Color(0.09, 0.25, 0.31, 1.0))
_nameLabel.add_theme_color_override("font_shadow_color", Color(1.0, 1.0, 1.0, 0.85))
_nameLabel.add_theme_constant_override("shadow_offset_x", 0)
_nameLabel.add_theme_constant_override("shadow_offset_y", 1)
_nameLabel.remove_theme_color_override("font_outline_color")
_nameLabel.remove_theme_constant_override("outline_size")
_nameLabel.add_theme_font_size_override("font_size", CAFE_NAME_LABEL_FONT_SIZE)
_nameLabel.add_theme_stylebox_override("normal", _create_cafe_name_label_style())
func _configure_default_name_label() -> void:
_nameLabel.theme = null
_nameLabel.position = NAME_LABEL_OFFSET
_nameLabel.scale = Vector2.ONE
_nameLabel.custom_minimum_size = Vector2(140, 28)
var displayName := username if not username.strip_edges().is_empty() else "玩家"
var visualWidth := _name_label_visual_width(displayName)
var renderWidth := ceili(float(visualWidth) / NAME_LABEL_RENDER_SCALE)
var renderHeight := ceili(float(NAME_LABEL_VISUAL_HEIGHT) / NAME_LABEL_RENDER_SCALE)
_nameLabel.theme = WORLD_TEXT_THEME
_nameLabel.z_index = 30
_nameLabel.scale = Vector2.ONE * NAME_LABEL_RENDER_SCALE
_nameLabel.position = Vector2(float(visualWidth) * -0.5, _name_label_offset_y())
_nameLabel.custom_minimum_size = Vector2(renderWidth, renderHeight)
_nameLabel.size = _nameLabel.custom_minimum_size
_nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_nameLabel.vertical_alignment = VERTICAL_ALIGNMENT_TOP
_nameLabel.clip_text = false
_nameLabel.text_overrun_behavior = TextServer.OVERRUN_NO_TRIMMING
_nameLabel.mouse_filter = Control.MOUSE_FILTER_STOP
_nameLabel.add_theme_color_override("font_color", Color(0.188, 0.294, 0.424))
_nameLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_nameLabel.clip_text = true
_nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
_nameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_nameLabel.add_theme_color_override("font_color", Color(0.09, 0.25, 0.31, 1.0))
_nameLabel.remove_theme_color_override("font_shadow_color")
_nameLabel.remove_theme_constant_override("shadow_offset_x")
_nameLabel.remove_theme_constant_override("shadow_offset_y")
_nameLabel.add_theme_font_size_override("font_size", 14)
_nameLabel.add_theme_stylebox_override("normal", _create_name_label_style())
_nameLabel.add_theme_color_override("font_outline_color", Color(1.0, 0.965, 0.88, 0.98))
_nameLabel.add_theme_constant_override("outline_size", 3)
_nameLabel.add_theme_font_override("font", _get_nickname_font())
_nameLabel.add_theme_font_size_override("font_size", NAME_LABEL_FONT_SIZE)
_nameLabel.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
func _name_label_visual_width(displayName: String) -> int:
var measuredWidth := _get_nickname_font().get_string_size(displayName, HORIZONTAL_ALIGNMENT_LEFT, -1, NAME_LABEL_FONT_SIZE).x
var visualWidth := ceili(measuredWidth * NAME_LABEL_RENDER_SCALE + 20.0)
return clampi(visualWidth, NAME_LABEL_VISUAL_MIN_WIDTH, NAME_LABEL_VISUAL_MAX_WIDTH)
func _get_nickname_font() -> FontFile:
if _nicknameFont == null:
_nicknameFont = NAME_LABEL_FONT_ZH.duplicate() as FontFile
_nicknameFont.fallbacks = [NAME_LABEL_FONT_LATIN]
return _nicknameFont
func _name_label_offset_y() -> float:
match lastDirection:
"left", "right":
return NAME_LABEL_SIDE_OFFSET_Y
"up":
return NAME_LABEL_BACK_OFFSET_Y
_:
return NAME_LABEL_OFFSET_Y
func _cafe_name_label_visual_width(displayName: String) -> int:
var estimatedWidth := displayName.length() * CAFE_NAME_LABEL_VISUAL_CHAR_WIDTH + 28
@@ -340,18 +451,9 @@ func _settings_bool(key: String, defaultValue: bool) -> bool:
return bool(settingsManager.call("get_bool", key))
return defaultValue
func _create_name_label_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(1.0, 1.0, 1.0, 0.74)
style.corner_radius_top_left = 10
style.corner_radius_top_right = 10
style.corner_radius_bottom_left = 10
style.corner_radius_bottom_right = 10
style.content_margin_left = 8
style.content_margin_right = 8
style.content_margin_top = 4
style.content_margin_bottom = 4
return style
func _is_guest_mode() -> bool:
var chatManager := get_node_or_null("/root/ChatManager")
return chatManager != null and chatManager.has_method("is_guest_mode") and bool(chatManager.call("is_guest_mode"))
func _create_cafe_name_label_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()

View File

@@ -0,0 +1,18 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="PackedScene" path="res://scenes/characters/npc.tscn" id="1_base"]
[ext_resource type="Script" path="res://scenes/characters/NetworkNpc.gd" id="2_script"]
[ext_resource type="Texture2D" path="res://assets/characters/generated/whale_researcher_v2/final_no_feet/processed/whale_researcher_no_feet_spritesheet.png" id="3_researcher"]
[node name="NetworkNpc" instance=ExtResource("1_base")]
script = ExtResource("2_script")
showNameplate = true
nameplateOffsetY = -72.0
[node name="Sprite2D" parent="." index="0"]
position = Vector2(0, -29)
scale = Vector2(0.5, 0.5)
texture_filter = 1
texture = ExtResource("3_researcher")
hframes = 8
vframes = 4

View File

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

View File

@@ -163,6 +163,41 @@ func set_message(from_user: String, content: String, timestamp: float, is_self:
# 应用样式
_apply_style()
# NPC 会话由外层复古对话框承载,单条内容不再绘制聊天气泡。
func set_dialogue_mode(enabled: bool) -> void:
if not enabled:
return
_cache_node_refs()
size_flags_horizontal = Control.SIZE_EXPAND_FILL
custom_minimum_size.y = 0.0
if message_row:
message_row.alignment = BoxContainer.ALIGNMENT_BEGIN
message_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
message_row.add_theme_constant_override("separation", 0)
if left_avatar_panel:
left_avatar_panel.visible = false
if right_avatar_panel:
right_avatar_panel.visible = false
if bubble_panel:
bubble_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
bubble_panel.custom_minimum_size.x = 0.0
bubble_panel.add_theme_stylebox_override("panel", StyleBoxEmpty.new())
if text_container:
text_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
text_container.add_theme_constant_override("separation", 4)
if user_info_container:
user_info_container.alignment = BoxContainer.ALIGNMENT_BEGIN
if username_label:
username_label.add_theme_color_override("font_color", Color(0.08, 0.24, 0.27, 1.0))
username_label.add_theme_font_size_override("font_size", 16)
if timestamp_label:
timestamp_label.visible = false
if content_label:
content_label.custom_minimum_size.x = 0.0
content_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
content_label.add_theme_color_override("default_color", Color(0.12, 0.18, 0.18, 1.0))
content_label.add_theme_font_size_override("normal_font_size", 17)
# ============================================================================
# 内部方法 - 样式处理
# ============================================================================

View File

@@ -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_REFERENCE_UPLOAD_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/reference_upload_box.png"
const REGISTRATION_CHOICE_IMAGE_PLACEHOLDER_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/image_placeholder_box.png"
const UI_FONT_PATH: String = "res://assets/fonts/msyh.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 WebFilePicker = preload("res://_Core/utils/WebFilePicker.gd")
const SKIN_GENERATION_CREATE_ENDPOINT: String = "/api/skin-generation/jobs"
const SKIN_GENERATION_POLL_ENDPOINT_TEMPLATE: String = "/api/skin-generation/jobs/%s"
const SKIN_GENERATION_POLL_INTERVAL: float = 2.0
const SKIN_GENERATION_REQUEST_TIMEOUT: float = 24.0
const AVATAR_NATIVE_FILE_FILTERS: Array[String] = ["*.png,*.jpg,*.jpeg,*.webp"]
const SKIN_NATIVE_FILE_FILTERS: Array[String] = ["*.png,*.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 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)
@@ -44,7 +46,7 @@ const AUTH_STAGE_VIEWPORT_RATIO: float = 0.92
const LOGIN_FRAME_RECT: Rect2 = Rect2(270, 40, 780, 680)
const REGISTER_FRAME_RECT: Rect2 = Rect2(150, 15, 1020, 727)
const LOGIN_FORM_RECT: Rect2 = Rect2(432, 210, 430, 430)
const REGISTER_FORM_RECT: Rect2 = Rect2(292, 155, 446, 500)
const REGISTER_FORM_RECT: Rect2 = Rect2(292, 128, 446, 500)
@onready var whale_frame: TextureRect = %WhaleFrame
@onready var login_panel: PanelContainer = %LoginPanel
@@ -53,8 +55,10 @@ const REGISTER_FORM_RECT: Rect2 = Rect2(292, 155, 446, 500)
@onready var login_identifier_input: LineEdit = %LoginIdentifierInput
@onready var login_password_input: LineEdit = %LoginPasswordInput
@onready var login_button: Button = %LoginButton
@onready var guest_button: Button = %GuestButton
@onready var show_register_button: Button = %ShowRegisterButton
@onready var register_username_input: LineEdit = %RegisterUsernameInput
@onready var register_invitation_code_input: LineEdit = %RegisterInvitationCodeInput
@onready var register_email_input: LineEdit = %RegisterEmailInput
@onready var send_register_code_button: Button = %SendRegisterCodeButton
@onready var register_verification_code_input: LineEdit = %RegisterVerificationCodeInput
@@ -91,11 +95,9 @@ var _scene_manager: Node
var _appearance_manager: Node
var _ui_font: FontFile
var _brand_font: SystemFont
var _choice_font: SystemFont
var _choice_font: Font
var _resuming_cached_session: bool = false
var _cached_resume_start_generation: int = 0
var _skin_generation_create_request: HTTPRequest
var _skin_generation_poll_request: HTTPRequest
var _skin_generation_active: bool = false
var _skin_generation_job_id: String = ""
var _skin_generation_poll_elapsed: float = 0.0
@@ -108,19 +110,25 @@ var _registration_official_skins: Array = []
var _registration_official_skin_index: int = 0
var _registration_avatar_source_path: String = ""
var _registration_skin_source_path: String = ""
var _registration_avatar_controls_visible: bool = false
var _workshop_source_image_path: String = ""
var _awaiting_registration_skin_generation: bool = false
var _awaiting_registration_skin_choice: bool = false
var _pending_profile_sync_after_auth: bool = false
var _deferred_enter_square_after_profile_sync: bool = false
var _is_sending_register_code: bool = false
var _web_file_picker: RefCounted = WebFilePicker.new()
func _enter_tree() -> void:
_ui_font = load(UI_FONT_PATH) as FontFile
_brand_font = SystemFont.new()
_brand_font.font_names = PackedStringArray(["Arial Rounded MT Bold", "Avenir Next", "Trebuchet MS", "Arial"])
_choice_font = SystemFont.new()
_choice_font.font_names = PackedStringArray(["STHeiti Medium", "Hiragino Sans GB", "PingFang SC", "Microsoft YaHei"])
if OS.get_name() == "Web":
_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()
func _build_v1_auth_ui() -> void:
@@ -203,7 +211,7 @@ func _build_login_form(parent: Control) -> void:
box.offset_right = -12
parent.add_child(box)
var title := _brand_label("TitleLabel", "WhaleTown V2")
var title := _brand_label("TitleLabel", "WhaleTown")
title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
box.add_child(title)
box.add_child(HSeparator.new())
@@ -240,6 +248,9 @@ func _build_login_form(parent: Control) -> void:
var loginButton := _primary_button("LoginButton", "进入小镇", 25, Vector2(396, 58))
loginButton.unique_name_in_owner = true
box.add_child(loginButton)
var guestButton := _secondary_button("GuestButton", "游客参观", 17, Vector2(396, 40))
guestButton.unique_name_in_owner = true
box.add_child(guestButton)
var links := HBoxContainer.new()
links.name = "BottomLinks"
@@ -261,7 +272,7 @@ func _build_register_form(parent: Control) -> void:
var box := VBoxContainer.new()
box.name = "RegisterVBox"
box.add_theme_constant_override("separation", 3)
_place(box, Rect2(8, 40, 422, 448))
_place(box, Rect2(8, 20, 422, 478))
content.add_child(box)
var title := _label("RegisterTitleLabel", "注册新居民", 22, TEXT_COLOR, HORIZONTAL_ALIGNMENT_CENTER)
@@ -274,6 +285,12 @@ func _build_register_form(parent: Control) -> void:
usernameInput.unique_name_in_owner = true
box.add_child(usernameInput)
box.add_child(_register_form_label("邀请码"))
var invitationCodeInput := _register_line_edit("RegisterInvitationCodeInput", "输入邀请码", false)
invitationCodeInput.unique_name_in_owner = true
invitationCodeInput.max_length = 30
box.add_child(invitationCodeInput)
box.add_child(_register_form_label("邮箱"))
var emailRow := HBoxContainer.new()
emailRow.name = "RegisterEmailRow"
@@ -305,7 +322,7 @@ func _build_register_form(parent: Control) -> void:
var buttonSpacer := Control.new()
buttonSpacer.name = "RegisterButtonSpacer"
buttonSpacer.custom_minimum_size = Vector2(0, 12)
buttonSpacer.custom_minimum_size = Vector2(0, 4)
box.add_child(buttonSpacer)
var buttons := HBoxContainer.new()
@@ -685,18 +702,27 @@ func _ready() -> void:
_scene_manager = get_node_or_null("/root/SceneManager")
_appearance_manager = get_node_or_null("/root/AppearanceManager")
_setup_skin_generation_requests()
_connect_signals()
_refresh_appearance_ui()
_show_login()
_notify_web_shell_ready()
if _scene_manager != null and _scene_manager.has_method("preload_scene_pack"):
_scene_manager.call("preload_scene_pack", "square")
if _auth_manager != null and bool(_auth_manager.call("is_authenticated")) and _should_auto_resume_cached_session():
_resume_cached_session()
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:
login_identifier_input.grab_focus()
func _connect_signals() -> void:
login_button.pressed.connect(_on_login_pressed)
guest_button.pressed.connect(_on_guest_pressed)
show_register_button.pressed.connect(_show_register)
send_register_code_button.pressed.connect(_on_send_register_code_pressed)
register_button.pressed.connect(_on_register_pressed)
@@ -725,19 +751,23 @@ func _connect_signals() -> void:
_auth_manager.connect("profile_update_succeeded", _on_profile_update_succeeded)
_auth_manager.connect("profile_update_failed", _on_profile_update_failed)
_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:
_skin_generation_create_request = HTTPRequest.new()
_skin_generation_create_request.name = "SkinGenerationCreateRequest"
_skin_generation_create_request.timeout = SKIN_GENERATION_REQUEST_TIMEOUT
_skin_generation_create_request.request_completed.connect(_on_skin_generation_create_completed)
add_child(_skin_generation_create_request)
func _notify_web_shell_ready() -> void:
if OS.get_name() != "Web" or not Engine.has_singleton("JavaScriptBridge"):
return
var bridge: Object = Engine.get_singleton("JavaScriptBridge")
bridge.eval("window.whaletownGodotReady && window.whaletownGodotReady()", true)
_skin_generation_poll_request = HTTPRequest.new()
_skin_generation_poll_request.name = "SkinGenerationPollRequest"
_skin_generation_poll_request.timeout = SKIN_GENERATION_REQUEST_TIMEOUT
_skin_generation_poll_request.request_completed.connect(_on_skin_generation_poll_completed)
add_child(_skin_generation_poll_request)
func _complete_browser_bootstrap(kind: String) -> void:
if _is_submitting:
return
_set_submitting(true, "正在准备小镇...")
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:
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()
func _exit_tree() -> void:
if is_instance_valid(_skin_generation_create_request):
_skin_generation_create_request.cancel_request()
if is_instance_valid(_skin_generation_poll_request):
_skin_generation_poll_request.cancel_request()
_skin_generation_active = false
_skin_generation_job_id = ""
@@ -905,6 +931,7 @@ func _set_registration_character_controls_visible(visible: bool) -> void:
_refresh_appearance_ui()
func _set_registration_avatar_controls_visible(visible: bool) -> void:
_registration_avatar_controls_visible = visible
if not is_instance_valid(registration_character_area):
return
var controlNames := [
@@ -937,6 +964,7 @@ func _refresh_character_preview() -> void:
character_sprite.modulate = Color.WHITE
character_sprite.frame = 0
character_sprite.scale = _sprite_scale_for_height(character_sprite.texture, character_sprite.vframes, MAIN_PREVIEW_HEIGHT)
character_sprite.position = Vector2(50, 70) + _sprite_visual_center_offset(character_sprite.texture, character_sprite.hframes, character_sprite.vframes, character_sprite.frame, character_sprite.scale)
_clear_workshop_preview()
return
if _appearance_manager.has_method("apply_skin_to_sprite"):
@@ -945,6 +973,7 @@ func _refresh_character_preview() -> void:
_appearance_manager.call("apply_skin_to_sprite", character_sprite, previewSkinId)
character_sprite.frame = 0
character_sprite.scale = _sprite_scale_for_height(character_sprite.texture, character_sprite.vframes, MAIN_PREVIEW_HEIGHT)
character_sprite.position = Vector2(50, 70) + _sprite_visual_center_offset(character_sprite.texture, character_sprite.hframes, character_sprite.vframes, character_sprite.frame, character_sprite.scale)
_clear_workshop_preview()
func _clear_workshop_preview() -> void:
@@ -975,6 +1004,7 @@ func _show_workshop_generated_preview_from_image(image: Image) -> void:
workshop_preview_sprite.vframes = 4
workshop_preview_sprite.frame = 0
workshop_preview_sprite.scale = _sprite_scale_for_height(texture, 4, 156.0)
workshop_preview_sprite.position = Vector2(75, 105) + _sprite_visual_center_offset(texture, 8, 4, workshop_preview_sprite.frame, workshop_preview_sprite.scale)
workshop_preview_sprite.visible = true
func _show_workshop_source_preview(path: String) -> void:
@@ -1071,6 +1101,7 @@ func _create_skin_button(skin: Dictionary, selectedSkinId: String) -> Button:
preview.frame = 0
preview.position = Vector2(90, 99)
preview.scale = _sprite_scale_for_frame_bounds(preview.texture, preview.hframes, preview.vframes, Vector2(136, SKIN_THUMB_HEIGHT))
preview.position += _sprite_visual_center_offset(preview.texture, preview.hframes, preview.vframes, preview.frame, preview.scale)
preview.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
button.add_child(preview)
@@ -1139,6 +1170,9 @@ func _skin_texture(skin: Dictionary) -> Texture2D:
func _refresh_avatar_preview() -> void:
avatar_preview.custom_minimum_size = Vector2(34, 34)
if not _registration_avatar_controls_visible:
avatar_preview.hide()
return
if _registration_avatar_texture == null:
_clear_registration_avatar_preview()
return
@@ -1171,7 +1205,7 @@ func _show_registration_avatar_preview(texture: Texture2D) -> void:
return
avatar_preview.show()
avatar_preview.clip_contents = true
avatar_preview.add_theme_stylebox_override("panel", _panel_style(Color.WHITE, 18))
avatar_preview.add_theme_stylebox_override("panel", _panel_style(Color.WHITE, 22, Color(0.45, 0.66, 0.84, 0.55), 1))
if is_instance_valid(avatar_label):
avatar_label.hide()
var textureRect := _get_or_create_registration_avatar_texture_rect()
@@ -1187,11 +1221,19 @@ func _get_or_create_registration_avatar_texture_rect() -> TextureRect:
textureRect.mouse_filter = Control.MOUSE_FILTER_IGNORE
textureRect.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
textureRect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
textureRect.stretch_mode = TextureRect.STRETCH_SCALE
textureRect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
textureRect.material = _create_avatar_mask_material()
textureRect.set_anchors_preset(Control.PRESET_FULL_RECT)
avatar_preview.add_child(textureRect)
return textureRect
func _create_avatar_mask_material() -> ShaderMaterial:
var material := ShaderMaterial.new()
material.shader = AVATAR_MASK_SHADER
material.set_shader_parameter("corner_radius", 0.22)
material.set_shader_parameter("edge_feather", 0.008)
return material
func _load_registration_avatar_texture(path: String) -> Texture2D:
var cropped := _load_registration_avatar_image(path)
if cropped == null:
@@ -1401,23 +1443,19 @@ func _on_generate_skin_pressed() -> void:
"source_image_base64": _image_to_png_base64(sourceImage),
"source_mime_type": "image/png",
}
var err := _skin_generation_create_request.request(
"%s%s" % [NetworkConfig.get_api_base_url(), SKIN_GENERATION_CREATE_ENDPOINT],
_auth_json_headers(),
HTTPClient.METHOD_POST,
JSON.stringify(payload)
)
if err != OK:
var apiClient := get_node_or_null("/root/ApiClient")
if apiClient == null or not apiClient.has_method("post_json"):
_skin_generation_active = false
_set_skin_generation_controls_enabled(true)
_set_workshop_generation_status("角色生成请求发送失败:%s" % error_string(err))
_set_workshop_generation_status("角色生成服务未加载")
return
apiClient.call("post_json", SKIN_GENERATION_CREATE_ENDPOINT, payload, _on_skin_generation_create_completed, true)
func _on_skin_generation_create_completed(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var parsed := _parse_skin_generation_response(result, responseCode, body)
if not bool(parsed.get("ok", false)):
func _on_skin_generation_create_completed(success: bool, response: Dictionary, errorInfo: Dictionary) -> void:
if not success:
_skin_generation_active = false
_set_skin_generation_controls_enabled(true)
var errorMessage := str(parsed.get("message", "角色生成任务创建失败"))
var errorMessage := str(errorInfo.get("message", "角色生成任务创建失败"))
if errorMessage.contains("已经使用过注册角色生成机会") or errorMessage.contains("没有可用的注册角色生成机会"):
_awaiting_registration_skin_generation = false
_set_workshop_generation_status("该账号已完成注册角色生成,正在进入小镇...")
@@ -1426,7 +1464,13 @@ func _on_skin_generation_create_completed(result: int, responseCode: int, _heade
_set_workshop_generation_status(errorMessage)
return
var data: Dictionary = parsed.get("data", {})
var dataVariant: Variant = response.get("data", {})
if not (dataVariant is Dictionary):
_skin_generation_active = false
_set_skin_generation_controls_enabled(true)
_set_workshop_generation_status("服务器返回的生成任务格式错误")
return
var data: Dictionary = dataVariant
_skin_generation_job_id = str(data.get("job_id", "")).strip_edges()
if _skin_generation_job_id.is_empty():
_skin_generation_active = false
@@ -1442,26 +1486,30 @@ func _poll_skin_generation_job() -> void:
_skin_generation_poll_in_flight = true
var endpoint := SKIN_GENERATION_POLL_ENDPOINT_TEMPLATE % _skin_generation_job_id
var err := _skin_generation_poll_request.request(
"%s%s" % [NetworkConfig.get_api_base_url(), endpoint],
_auth_json_headers(),
HTTPClient.METHOD_GET
)
if err != OK:
var apiClient := get_node_or_null("/root/ApiClient")
if apiClient == null or not apiClient.has_method("get_json"):
_skin_generation_poll_in_flight = false
_set_workshop_generation_status("查询生成状态失败:%s" % error_string(err))
_set_workshop_generation_status("角色生成服务未加载")
return
apiClient.call("get_json", endpoint, _on_skin_generation_poll_completed, true)
func _on_skin_generation_poll_completed(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
func _on_skin_generation_poll_completed(success: bool, response: Dictionary, errorInfo: Dictionary) -> void:
_skin_generation_poll_in_flight = false
var parsed := _parse_skin_generation_response(result, responseCode, body)
if not bool(parsed.get("ok", false)):
if not success:
var responseCode := int(errorInfo.get("response_code", 0))
if responseCode == 401 or responseCode == 403 or responseCode == 404:
_skin_generation_active = false
_set_skin_generation_controls_enabled(true)
_set_workshop_generation_status(str(parsed.get("message", "查询生成状态失败")))
_set_workshop_generation_status(str(errorInfo.get("message", "查询生成状态失败")))
return
var data: Dictionary = parsed.get("data", {})
var dataVariant: Variant = response.get("data", {})
if not (dataVariant is Dictionary):
_skin_generation_active = false
_set_skin_generation_controls_enabled(true)
_set_workshop_generation_status("服务器返回的生成状态格式错误")
return
var data: Dictionary = dataVariant
var status := str(data.get("status", "")).strip_edges()
var message := str(data.get("message", "")).strip_edges()
if not message.is_empty():
@@ -1547,74 +1595,6 @@ func _set_skin_generation_controls_enabled(enabled: bool) -> void:
if is_instance_valid(workshop_generate_button):
workshop_generate_button.disabled = not enabled
func _json_headers() -> PackedStringArray:
return PackedStringArray([
"Content-Type: application/json",
"Accept: application/json",
])
func _auth_json_headers() -> PackedStringArray:
var headers := _json_headers()
if _auth_manager != null and _auth_manager.has_method("get_access_token"):
var token := str(_auth_manager.call("get_access_token")).strip_edges()
if not token.is_empty():
headers.append("Authorization: Bearer %s" % token)
return headers
func _parse_skin_generation_response(result: int, responseCode: int, body: PackedByteArray) -> Dictionary:
if result != HTTPRequest.RESULT_SUCCESS:
return {
"ok": false,
"message": "网络请求失败:%s" % _http_result_to_string(result),
}
var bodyText := body.get_string_from_utf8()
var json := JSON.new()
var error := json.parse(bodyText)
if error != OK or not (json.data is Dictionary):
return {
"ok": false,
"message": "服务器响应解析失败",
}
var response: Dictionary = json.data as Dictionary
var success := responseCode >= 200 and responseCode < 300 and bool(response.get("success", true))
if not success:
return {
"ok": false,
"message": str(response.get("message", "请求失败")),
"response_code": responseCode,
}
var dataVariant: Variant = response.get("data", response)
if not (dataVariant is Dictionary):
return {
"ok": false,
"message": "服务器响应格式错误",
}
return {
"ok": true,
"data": dataVariant as Dictionary,
}
func _http_result_to_string(result: int) -> String:
match result:
HTTPRequest.RESULT_SUCCESS:
return "SUCCESS"
HTTPRequest.RESULT_TIMEOUT:
return "TIMEOUT"
HTTPRequest.RESULT_CANT_CONNECT:
return "CANT_CONNECT"
HTTPRequest.RESULT_CANT_RESOLVE:
return "CANT_RESOLVE"
HTTPRequest.RESULT_CONNECTION_ERROR:
return "CONNECTION_ERROR"
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
return "TLS_HANDSHAKE_ERROR"
_:
return "UNKNOWN_%d" % result
func _hide_skin_workshop() -> void:
if _awaiting_registration_skin_generation:
_awaiting_registration_skin_generation = false
@@ -1626,6 +1606,9 @@ func _hide_skin_workshop() -> void:
skin_workshop_overlay.hide()
func _on_workshop_source_pressed() -> void:
if _open_web_file_picker("workshop"):
_set_workshop_generation_status("请选择角色参考图片")
return
var err := DisplayServer.file_dialog_show(
"选择角色参考图片",
_default_picker_dir(),
@@ -1680,6 +1663,9 @@ func _update_skin_library_buttons() -> void:
_apply_library_arrow_style(skin_library_next_button, true)
func _on_upload_avatar_pressed() -> void:
if _open_web_file_picker("avatar"):
status_label.text = "请选择头像图片"
return
var err := DisplayServer.file_dialog_show(
"选择头像图片",
_default_picker_dir(),
@@ -1698,6 +1684,11 @@ func _on_upload_avatar_pressed() -> void:
status_label.text = "系统文件选择器未打开:%s / %s" % [DisplayServer.get_name(), error_string(err)]
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(
"选择8x4角色皮肤PNG",
_default_picker_dir(),
@@ -1719,6 +1710,46 @@ func _on_upload_skin_pressed() -> void:
return
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:
if OS.get_name() != "macOS":
return false
@@ -1871,7 +1902,7 @@ func _on_send_register_code_pressed() -> void:
_is_sending_register_code = true
send_register_code_button.disabled = true
status_label.text = "正在发送邮箱验证码..."
_auth_manager.call("send_email_verification", register_email_input.text)
_auth_manager.call("send_email_verification", register_email_input.text, register_invitation_code_input.text)
func _on_register_pressed() -> void:
if _is_submitting:
@@ -1894,7 +1925,8 @@ func _on_register_pressed() -> void:
"",
register_email_input.text,
register_verification_code_input.text,
_registration_backend_initial_skin_id()
_registration_backend_initial_skin_id(),
register_invitation_code_input.text
)
func _on_login_succeeded(_user: Dictionary) -> void:
@@ -1905,6 +1937,17 @@ func _on_login_succeeded(_user: Dictionary) -> void:
_auth_manager.call("fetch_profile")
_enter_square()
func _on_guest_pressed() -> void:
if _is_submitting:
return
var chatManager := get_node_or_null("/root/ChatManager")
if chatManager == null or not chatManager.has_method("start_guest_session"):
status_label.text = "旁观服务未加载"
return
_set_submitting(true, "正在进入游客参观模式...")
chatManager.call("start_guest_session")
SceneManager.change_scene("square")
func _on_login_failed(message: String) -> void:
_set_submitting(false, message)
login_password_input.grab_focus()
@@ -2110,6 +2153,7 @@ func _set_submitting(is_submitting: bool, message: String) -> void:
_is_submitting = is_submitting
status_label.text = message
login_button.disabled = is_submitting
guest_button.disabled = is_submitting
register_button.disabled = is_submitting
send_register_code_button.disabled = is_submitting or _is_sending_register_code
show_register_button.disabled = is_submitting
@@ -2371,3 +2415,35 @@ func _sprite_scale_for_frame_bounds(texture: Texture2D, hframes: int, vframes: i
return Vector2.ONE
var scale: float = minf(bounds.x / frameSize.x, bounds.y / frameSize.y)
return Vector2(scale, scale)
func _sprite_visual_center_offset(texture: Texture2D, hframes: int, vframes: int, frame: int, scale: Vector2) -> Vector2:
if texture == null:
return Vector2.ZERO
var image := texture.get_image()
if image == null:
return Vector2.ZERO
var safeHframes := maxi(1, hframes)
var safeVframes := maxi(1, vframes)
var frameWidth := image.get_width() / safeHframes
var frameHeight := image.get_height() / safeVframes
if frameWidth <= 0 or frameHeight <= 0:
return Vector2.ZERO
var safeFrame := clampi(frame, 0, safeHframes * safeVframes - 1)
var frameOrigin := Vector2i((safeFrame % safeHframes) * frameWidth, (safeFrame / safeHframes) * frameHeight)
var minX := frameWidth
var minY := frameHeight
var maxX := -1
var maxY := -1
for y in range(frameHeight):
for x in range(frameWidth):
if image.get_pixel(frameOrigin.x + x, frameOrigin.y + y).a <= 0.08:
continue
minX = mini(minX, x)
minY = mini(minY, y)
maxX = maxi(maxX, x)
maxY = maxi(maxY, y)
if maxX < minX or maxY < minY:
return Vector2.ZERO
var visualCenter := Vector2((float(minX) + float(maxX) + 1.0) * 0.5, (float(minY) + float(maxY) + 1.0) * 0.5)
var frameCenter := Vector2(float(frameWidth), float(frameHeight)) * 0.5
return (frameCenter - visualCenter) * scale

View File

@@ -51,13 +51,13 @@ func _draw_polyline(points: Array[Vector2], color: Color, width: float) -> void:
var packed: PackedVector2Array = []
for point in points:
packed.append(_p(point))
draw_polyline(packed, color, width, true)
draw_polyline(packed, color, width * _iconScale, false)
func _draw_line(from: Vector2, to: Vector2, color: Color, width: float) -> void:
draw_line(_p(from), _p(to), color, width, true)
draw_line(_p(from), _p(to), color, width * _iconScale, false)
func _draw_arc(center: Vector2, radius: float, startAngle: float, endAngle: float, pointCount: int, color: Color, width: float) -> void:
draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, color, width, true)
draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, color, width * _iconScale, false)
func _p(point: Vector2) -> Vector2:
return _iconOffset + point * _iconScale

View File

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

View File

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

View File

@@ -56,6 +56,9 @@ extends Control
@onready var friends_list_surface: Control = %FriendsListSurface
@onready var add_friend_row: HBoxContainer = %AddFriendRow
@onready var add_friend_button: Button = %AddFriendButton
@onready var tabs: HBoxContainer = $ChatPanel/PanelMargin/ContentVBox/Tabs
@onready var panel_margin: MarginContainer = $ChatPanel/PanelMargin
@onready var input_row: HBoxContainer = $ChatPanel/PanelMargin/ContentVBox/InputRow
# ============================================================================
# 预加载资源
@@ -80,6 +83,9 @@ const TAB_WHISPER: String = "whisper"
const TAB_FRIENDS: String = "friends"
const MAX_DISPLAYED_MESSAGES: int = 100
const MOVEMENT_ACTIONS: Array[String] = ["move_left", "move_right", "move_up", "move_down"]
const SEND_ICON := preload("res://assets/ui/world_bulletin/world_bulletin_send_v2_128.png")
const STATIC_NPC_DIALOGUE_PREFIX: String = "static_npc_dialogue:"
const DEFAULT_WHISPER_TAB_TEXT: String = "悄悄话"
# ============================================================================
# 成员变量
@@ -106,6 +112,9 @@ var _current_tab: String = TAB_WORLD
# 悄悄话目标(靠近玩家按 E 后设置)
var _whisper_target_user_id: String = ""
var _whisper_target_username: String = ""
var _npc_target_id: String = ""
var _npc_target_name: String = ""
var _npc_session_id: String = ""
# 好友私聊目标(好友列表接入后复用同一私聊协议)
var _friend_target_user_id: String = ""
@@ -118,6 +127,12 @@ var _friends_status_message: String = ""
var _messages: Array[Dictionary] = []
var _send_failure_handled_by_ui: bool = false
var _npc_thinking_row: Control
var _npc_thinking_timer: Timer
var _npc_dialogue_mode: bool = false
var _npc_dialogue_read_only: bool = false
var _default_chat_panel_style: StyleBox
var _default_input_shell_style: StyleBox
# ============================================================================
# 生命周期方法
@@ -126,12 +141,17 @@ var _send_failure_handled_by_ui: bool = false
# 准备就绪
func _ready() -> void:
_configure_mouse_focus()
_configure_send_icon()
_capture_default_dialogue_styles()
if not get_viewport().size_changed.is_connected(_on_viewport_size_changed):
get_viewport().size_changed.connect(_on_viewport_size_changed)
# 初始隐藏聊天框
hide_chat(true)
# 创建隐藏计时器
_create_hide_timer()
_create_npc_thinking_timer()
# 订阅事件Call Down via EventSystem
_subscribe_to_events()
@@ -144,6 +164,134 @@ func _ready() -> void:
_update_tab_visuals()
_update_bubble_send_button_visibility()
func _configure_send_icon() -> void:
if not is_instance_valid(send_button):
return
send_button.text = ""
send_button.icon = SEND_ICON
send_button.expand_icon = true
send_button.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
func _capture_default_dialogue_styles() -> void:
if is_instance_valid(chat_panel):
_default_chat_panel_style = chat_panel.get_theme_stylebox("panel").duplicate() as StyleBox
if is_instance_valid(input_shell):
_default_input_shell_style = input_shell.get_theme_stylebox("panel").duplicate() as StyleBox
func _set_npc_dialogue_mode(enabled: bool, readOnly: bool = false) -> void:
_npc_dialogue_mode = enabled
_npc_dialogue_read_only = enabled and readOnly
var worldTab := popular_tab_button.get_parent() as Control if is_instance_valid(popular_tab_button) else null
var whisperTab := recent_tab_button.get_parent() as Control if is_instance_valid(recent_tab_button) else null
var friendsTab := friends_tab_button.get_parent() as Control if is_instance_valid(friends_tab_button) else null
if is_instance_valid(worldTab):
worldTab.visible = not enabled
if is_instance_valid(friendsTab):
friendsTab.visible = not enabled
if is_instance_valid(whisperTab):
whisperTab.visible = true
if is_instance_valid(recent_tab_button):
recent_tab_button.text = _npc_target_name if enabled else DEFAULT_WHISPER_TAB_TEXT
recent_tab_button.alignment = HORIZONTAL_ALIGNMENT_LEFT if enabled else HORIZONTAL_ALIGNMENT_CENTER
recent_tab_button.mouse_filter = Control.MOUSE_FILTER_IGNORE if enabled else Control.MOUSE_FILTER_STOP
if is_instance_valid(input_row):
input_row.visible = not _npc_dialogue_read_only
if is_instance_valid(chat_panel):
var panelStyle := _create_npc_dialogue_style() if enabled else _default_chat_panel_style
if panelStyle != null:
chat_panel.add_theme_stylebox_override("panel", panelStyle)
if is_instance_valid(input_shell):
var inputStyle := _create_npc_dialogue_input_style() if enabled else _default_input_shell_style
if inputStyle != null:
input_shell.add_theme_stylebox_override("panel", inputStyle)
if is_instance_valid(panel_margin):
var horizontalMargin := 24 if enabled else 20
var verticalMargin := 18
panel_margin.add_theme_constant_override("margin_left", horizontalMargin)
panel_margin.add_theme_constant_override("margin_top", verticalMargin)
panel_margin.add_theme_constant_override("margin_right", horizontalMargin)
panel_margin.add_theme_constant_override("margin_bottom", verticalMargin)
_apply_chat_panel_layout()
_update_tab_visuals()
if is_instance_valid(message_list):
_rerender_messages()
func _clear_npc_dialogue_target() -> void:
_npc_target_id = ""
_npc_target_name = ""
_npc_session_id = ""
_set_npc_dialogue_mode(false, false)
func _apply_chat_panel_layout() -> void:
if not is_instance_valid(chat_panel):
return
if not _npc_dialogue_mode:
chat_panel.anchor_left = 0.024
chat_panel.anchor_top = 1.0
chat_panel.anchor_right = 0.024
chat_panel.anchor_bottom = 1.0
chat_panel.offset_left = 0.0
chat_panel.offset_top = -448.0
chat_panel.offset_right = 548.0
chat_panel.offset_bottom = -32.0
return
var viewportSize := get_viewport_rect().size
var dialogWidth := clampf(viewportSize.x * 0.90, 320.0, 760.0)
var minimumHeight := 200.0 if _npc_dialogue_read_only else 250.0
var maximumHeight := 250.0 if _npc_dialogue_read_only else 340.0
var dialogHeight := clampf(viewportSize.y * 0.42, minimumHeight, maximumHeight)
var bottomMargin := clampf(viewportSize.y * 0.04, 18.0, 34.0)
chat_panel.anchor_left = 0.5
chat_panel.anchor_top = 1.0
chat_panel.anchor_right = 0.5
chat_panel.anchor_bottom = 1.0
chat_panel.offset_left = -dialogWidth * 0.5
chat_panel.offset_top = -bottomMargin - dialogHeight
chat_panel.offset_right = dialogWidth * 0.5
chat_panel.offset_bottom = -bottomMargin
func _on_viewport_size_changed() -> void:
_apply_chat_panel_layout()
func _create_npc_dialogue_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(1.0, 0.985, 0.91, 0.985)
style.border_color = Color(0.055, 0.22, 0.235, 1.0)
style.border_width_left = 4
style.border_width_top = 4
style.border_width_right = 4
style.border_width_bottom = 4
style.corner_radius_top_left = 5
style.corner_radius_top_right = 5
style.corner_radius_bottom_left = 5
style.corner_radius_bottom_right = 5
style.shadow_color = Color(0.02, 0.06, 0.07, 0.3)
style.shadow_size = 8
style.shadow_offset = Vector2(0, 5)
return style
func _create_npc_dialogue_input_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(1.0, 1.0, 0.98, 1.0)
style.border_color = Color(0.22, 0.42, 0.42, 0.82)
style.border_width_left = 2
style.border_width_top = 2
style.border_width_right = 2
style.border_width_bottom = 2
style.corner_radius_top_left = 4
style.corner_radius_top_right = 4
style.corner_radius_bottom_left = 4
style.corner_radius_bottom_right = 4
style.content_margin_left = 14
style.content_margin_top = 6
style.content_margin_right = 14
style.content_margin_bottom = 6
return style
# 清理
func _exit_tree() -> void:
# 取消事件订阅
@@ -155,6 +303,8 @@ func _exit_tree() -> void:
eventSystem.call("disconnect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_login_success, self)
eventSystem.call("disconnect_event", EventNames.CHAT_LOGIN_FAILED, _on_login_failed, self)
eventSystem.call("disconnect_event", EventNames.CHAT_PRIVATE_TARGET_SELECTED, _on_private_target_selected, self)
eventSystem.call("disconnect_event", EventNames.NPC_SPOKE, _on_npc_spoke, self)
eventSystem.call("disconnect_event", EventNames.NPC_INTERACTION_ERROR, _on_npc_interaction_error, self)
eventSystem.call("disconnect_event", EventNames.CHAT_FRIEND_SELECTED, _on_friend_selected, self)
eventSystem.call("disconnect_event", EventNames.CHAT_FRIENDS_UPDATED, _on_friends_updated, self)
eventSystem.call("disconnect_event", EventNames.SETTINGS_CHANGED, _on_settings_changed, self)
@@ -162,6 +312,11 @@ func _exit_tree() -> void:
# 清理计时器
if _hide_timer:
_hide_timer.queue_free()
if _npc_thinking_timer:
_npc_thinking_timer.queue_free()
_hide_npc_thinking()
if get_viewport() != null and get_viewport().size_changed.is_connected(_on_viewport_size_changed):
get_viewport().size_changed.disconnect(_on_viewport_size_changed)
if is_instance_valid(_transition_tween):
_transition_tween.kill()
@@ -192,6 +347,10 @@ func _input(event: InputEvent) -> void:
var key_event := event as InputEventKey
if not key_event.pressed or key_event.echo:
return
if key_event.keycode == KEY_ESCAPE and _is_chat_visible:
hide_chat()
get_viewport().set_input_as_handled()
return
# T 键用于唤起聊天(输入框聚焦时不拦截)
if key_event.keycode == KEY_T and not chat_input.has_focus():
@@ -264,6 +423,9 @@ func _update_input_placeholder() -> void:
if _current_tab == TAB_WHISPER and not _whisper_target_username.is_empty():
chat_input.placeholder_text = "%s 说点什么..." % _whisper_target_username
return
if _current_tab == TAB_WHISPER and not _npc_target_name.is_empty():
chat_input.placeholder_text = "%s 说点什么..." % _npc_target_name
return
if _current_tab == TAB_FRIENDS and not _friend_target_username.is_empty():
chat_input.placeholder_text = "%s 说点什么..." % _friend_target_username
@@ -338,6 +500,8 @@ func show_chat(immediate: bool = false) -> void:
# 隐藏聊天框
func hide_chat(immediate: bool = false) -> void:
_end_npc_session()
_clear_npc_dialogue_target()
_is_chat_visible = false
_is_typing = false
@@ -374,6 +538,13 @@ func _create_hide_timer() -> void:
_hide_timer.timeout.connect(_on_hide_timeout)
add_child(_hide_timer)
func _create_npc_thinking_timer() -> void:
_npc_thinking_timer = Timer.new()
_npc_thinking_timer.wait_time = 60.0
_npc_thinking_timer.one_shot = true
_npc_thinking_timer.timeout.connect(_on_npc_thinking_timeout)
add_child(_npc_thinking_timer)
# 开始隐藏倒计时
func _start_hide_timer() -> void:
if _is_typing:
@@ -508,6 +679,17 @@ func _send_input_message(show_bubble: bool) -> void:
# 清空输入框
chat_input.clear()
if not _npc_target_id.is_empty() and _current_tab == TAB_WHISPER:
_add_message_data({
"from_user": _current_username,
"content": content,
"timestamp": Time.get_unix_time_from_system(),
"is_self": true,
"scope": "private",
"private_context": TAB_WHISPER,
"npc_id": _npc_target_id,
})
_show_npc_thinking()
# 发送后延迟重新聚焦,避免被 LineEdit 的提交事件在同一帧内抢走焦点
call_deferred("_focus_input_after_send")
@@ -565,6 +747,8 @@ func _subscribe_to_events() -> void:
# 订阅近身悄悄话目标选择事件
eventSystem.call("connect_event", EventNames.CHAT_PRIVATE_TARGET_SELECTED, _on_private_target_selected, self)
eventSystem.call("connect_event", EventNames.NPC_SPOKE, _on_npc_spoke, self)
eventSystem.call("connect_event", EventNames.NPC_INTERACTION_ERROR, _on_npc_interaction_error, self)
# 订阅右下角好友列表的好友选择事件
eventSystem.call("connect_event", EventNames.CHAT_FRIEND_SELECTED, _on_friend_selected, self)
@@ -611,9 +795,83 @@ func _on_chat_error(data: Dictionary) -> void:
if _current_tab == TAB_FRIENDS:
_render_friend_conversation_header()
return
if not _npc_target_id.is_empty() and _current_tab == TAB_WHISPER:
_hide_npc_thinking()
if not message.strip_edges().is_empty():
_add_system_message(message)
func _on_npc_spoke(data: Dictionary) -> void:
var npc_id := str(data.get("npc_id", data.get("npcId", ""))).strip_edges()
if _npc_target_id.is_empty() or npc_id != _npc_target_id:
return
var response := str(data.get("response", "")).strip_edges()
if response.is_empty():
return
_hide_npc_thinking()
var session_id := str(data.get("session_id", data.get("sessionId", ""))).strip_edges()
if not session_id.is_empty():
_npc_session_id = session_id
_add_message_data({
"from_user": str(data.get("npc_name", data.get("npcName", _npc_target_name))),
"content": response,
"timestamp": Time.get_unix_time_from_system(),
"is_self": false,
"scope": "private",
"private_context": TAB_WHISPER,
"npc_id": npc_id,
})
func _on_npc_interaction_error(data: Dictionary) -> void:
var npc_id := str(data.get("npc_id", data.get("npcId", ""))).strip_edges()
if not _npc_target_id.is_empty() and not npc_id.is_empty() and npc_id != _npc_target_id:
return
_hide_npc_thinking()
var message := str(data.get("message", "NPC暂时无法回应")).strip_edges()
if not message.is_empty():
_add_system_message(message)
func _show_npc_thinking() -> void:
_hide_npc_thinking()
if _npc_target_id.is_empty() or _current_tab != TAB_WHISPER:
return
if not is_instance_valid(message_list):
return
var row := HBoxContainer.new()
row.name = "NpcThinkingRow"
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
message_list.add_child(row)
var message_node := chat_message_scene.instantiate() as Control
if message_node == null:
row.queue_free()
return
row.add_child(message_node)
if message_node.has_method("set_message"):
message_node.call("set_message", _npc_target_name, "正在思考...", Time.get_unix_time_from_system(), false)
if message_node.has_method("set_dialogue_mode"):
message_node.call("set_dialogue_mode", _npc_dialogue_mode)
message_node.modulate = Color(1.0, 1.0, 1.0, 0.78)
_npc_thinking_row = row
if is_instance_valid(_npc_thinking_timer):
_npc_thinking_timer.start()
call_deferred("_scroll_to_bottom")
func _hide_npc_thinking() -> void:
if is_instance_valid(_npc_thinking_timer):
_npc_thinking_timer.stop()
if is_instance_valid(_npc_thinking_row):
_npc_thinking_row.queue_free()
_npc_thinking_row = null
func _on_npc_thinking_timeout() -> void:
if not is_instance_valid(_npc_thinking_row):
return
_hide_npc_thinking()
_add_system_message("NPC暂时没有回应请稍后再试")
# 处理连接状态变化
func _on_connection_state_changed(_data: Dictionary) -> void:
# 连接状态变化处理当前不更新UI
@@ -699,6 +957,8 @@ func _send_current_tab_message(chatManager: Node, content: String, show_bubble:
TAB_WORLD:
return bool(chatManager.call("send_chat_message", content, "global", show_bubble))
TAB_WHISPER:
if not _npc_target_id.is_empty():
return bool(chatManager.call("interact_with_world_npc", _npc_target_id, content, _npc_session_id))
if _whisper_target_user_id.is_empty():
_add_system_message("请靠近玩家按 E 发起悄悄话")
_send_failure_handled_by_ui = true
@@ -734,6 +994,8 @@ func start_whisper(user_id: String, username: String = "") -> void:
if normalized_user_id.is_empty():
return
_end_npc_session()
_clear_npc_dialogue_target()
_whisper_target_user_id = normalized_user_id
_whisper_target_username = username.strip_edges()
if _whisper_target_username.is_empty():
@@ -744,6 +1006,66 @@ func start_whisper(user_id: String, username: String = "") -> void:
show_chat(true)
call_deferred("_focus_input_after_send")
func start_npc_whisper(npc_id: String, npc_name: String = "NPC", greeting: String = "") -> void:
var normalized_id := npc_id.strip_edges()
if normalized_id.is_empty():
return
_end_npc_session()
_clear_npc_dialogue_target()
_hide_npc_thinking()
_npc_target_id = normalized_id
_npc_target_name = npc_name.strip_edges() if not npc_name.strip_edges().is_empty() else "NPC"
_npc_session_id = ""
_whisper_target_user_id = ""
_whisper_target_username = ""
select_tab(TAB_WHISPER)
_set_npc_dialogue_mode(true, false)
show_chat(true)
if not greeting.strip_edges().is_empty():
_add_message_data({
"from_user": _npc_target_name,
"content": greeting.strip_edges(),
"timestamp": Time.get_unix_time_from_system(),
"is_self": false,
"scope": "private",
"private_context": TAB_WHISPER,
"npc_id": _npc_target_id,
})
call_deferred("_focus_input_after_send")
func show_npc_dialogue(npc_name: String, text: String) -> void:
var normalizedText := text.strip_edges()
if normalizedText.is_empty():
return
_end_npc_session()
_clear_npc_dialogue_target()
_hide_npc_thinking()
_npc_target_name = npc_name.strip_edges() if not npc_name.strip_edges().is_empty() else "NPC"
_npc_target_id = "%s%s:%d" % [STATIC_NPC_DIALOGUE_PREFIX, _npc_target_name, Time.get_ticks_msec()]
_npc_session_id = ""
_whisper_target_user_id = ""
_whisper_target_username = ""
select_tab(TAB_WHISPER)
_set_npc_dialogue_mode(true, true)
show_chat(true)
_add_message_data({
"from_user": _npc_target_name,
"content": normalizedText,
"timestamp": Time.get_unix_time_from_system(),
"is_self": false,
"scope": "private",
"private_context": TAB_WHISPER,
"npc_id": _npc_target_id,
})
func _end_npc_session() -> void:
if _npc_target_id.is_empty() or _npc_session_id.is_empty() or _npc_target_id.begins_with(STATIC_NPC_DIALOGUE_PREFIX):
return
var chat_manager := _get_chat_manager()
if chat_manager != null and chat_manager.has_method("end_world_npc_session"):
chat_manager.call("end_world_npc_session", _npc_target_id, _npc_session_id)
_npc_session_id = ""
func add_whisper_target_as_friend() -> bool:
if _whisper_target_user_id.is_empty():
_add_system_message("请先靠近玩家按 F")
@@ -780,6 +1102,8 @@ func select_friend_private_target(user_id: String, username: String = "") -> voi
if normalized_user_id.is_empty():
return
_end_npc_session()
_clear_npc_dialogue_target()
_friend_target_user_id = normalized_user_id
_friend_target_username = username.strip_edges()
if _friend_target_username.is_empty():
@@ -934,15 +1258,23 @@ func _add_message_data(message: Dictionary) -> void:
_render_message(message)
func _render_message(message: Dictionary) -> void:
# 如果聊天框隐藏,自动显示
# 世界频道消息进入公告 HUD不自动抢占地图视野
# 私聊、NPC 回复和用户主动发送的消息仍可唤起聊天面板。
if not _is_chat_visible:
var message_scope := str(message.get("scope", "global")).strip_edges().to_lower()
var is_private := bool(message.get("is_private", false)) or message_scope == "private"
var is_npc := not str(message.get("npc_id", "")).strip_edges().is_empty()
# 系统存在/欢迎消息也属于世界公告,不应自动打开旧聊天窗口。
# 只有私聊和 NPC 会话需要在收到回复时主动唤起聊天 UI。
if not is_private and not is_npc:
return
show_chat()
# 每条消息用一行容器包起来,方便左右对齐且不挤在一起
var row := HBoxContainer.new()
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
row.alignment = BoxContainer.ALIGNMENT_END if bool(message.get("is_self", false)) else BoxContainer.ALIGNMENT_BEGIN
row.alignment = BoxContainer.ALIGNMENT_BEGIN if _npc_dialogue_mode else (BoxContainer.ALIGNMENT_END if bool(message.get("is_self", false)) else BoxContainer.ALIGNMENT_BEGIN)
# 创建消息节点
var message_node: Control = chat_message_scene.instantiate() as Control
@@ -962,6 +1294,8 @@ func _render_message(message: Dictionary) -> void:
float(message.get("timestamp", 0.0)),
bool(message.get("is_self", false))
)
if message_node.has_method("set_dialogue_mode"):
message_node.call("set_dialogue_mode", _npc_dialogue_mode)
# 自动滚动到底部
call_deferred("_scroll_to_bottom")
@@ -993,6 +1327,9 @@ func _message_matches_current_tab(message: Dictionary) -> bool:
return true
func _private_message_matches_current_tab(message: Dictionary) -> bool:
var npc_id := str(message.get("npc_id", message.get("npcId", ""))).strip_edges()
if _current_tab == TAB_WHISPER and not _npc_target_id.is_empty():
return npc_id == _npc_target_id
var scope := str(message.get("scope", "local")).strip_edges().to_lower()
var is_private := bool(message.get("is_private", false)) or scope == "private"
if not is_private:

View File

@@ -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/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"]
bg_color = Color(1, 0.996, 0.984, 0.97)
@@ -145,6 +146,7 @@ anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
theme = ExtResource("3")
script = ExtResource("1")
[node name="ChatPanel" type="PanelContainer" parent="."]

View File

@@ -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_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 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_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")
@@ -1126,8 +1126,8 @@ func _podiumMetaLine(data: Dictionary) -> String:
if location.is_empty():
return domain
if domain.is_empty():
return "📍 %s" % location
return "📍 %s · %s" % [location, domain]
return location
return "%s · %s" % [location, domain]
func _podiumTags(data: Dictionary) -> Array[String]:
var tags: Array[String] = []
@@ -1165,7 +1165,13 @@ func _avatarPrimaryTextureFromData(data: Dictionary) -> Texture2D:
return null
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:
for key in keys:

View File

@@ -9,8 +9,8 @@ extends Control
const LINE_COLOR: Color = Color(0.592157, 0.72549, 0.835294, 0.82)
const DOT_COLOR: Color = Color(0.941176, 0.54902, 0.54902, 0.86)
const DOT_BORDER_COLOR: Color = Color(1.0, 1.0, 1.0, 0.95)
const LINE_WIDTH: float = 0.85
const DETAIL_WIDTH: float = 0.75
const LINE_WIDTH: float = 1.15
const DETAIL_WIDTH: float = 1.0
const BASE_SIZE: float = 27.0
var iconName: String = "map"
@@ -127,17 +127,17 @@ func _draw_polyline(points: Array[Vector2], width: float = LINE_WIDTH) -> void:
var packed: PackedVector2Array = []
for point in points:
packed.append(_p(point))
draw_polyline(packed, LINE_COLOR, width, true)
draw_polyline(packed, LINE_COLOR, width * _iconScale, false)
func _draw_red_dot(center: Vector2) -> void:
draw_circle(_p(center), 2.3 * _iconScale, DOT_BORDER_COLOR)
draw_circle(_p(center), 1.55 * _iconScale, DOT_COLOR)
draw_circle(_p(center), 2.3 * _iconScale, DOT_BORDER_COLOR, true, -1.0, false)
draw_circle(_p(center), 1.55 * _iconScale, DOT_COLOR, true, -1.0, false)
func _draw_line(from: Vector2, to: Vector2, width: float) -> void:
draw_line(_p(from), _p(to), LINE_COLOR, width, true)
draw_line(_p(from), _p(to), LINE_COLOR, width * _iconScale, false)
func _draw_arc(center: Vector2, radius: float, startAngle: float, endAngle: float, pointCount: int, width: float) -> void:
draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, LINE_COLOR, width, true)
draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, LINE_COLOR, width * _iconScale, false)
func _p(point: Vector2) -> Vector2:
return _iconOffset + point * _iconScale

View File

@@ -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="Theme" path="res://assets/ui/world_text_theme.tres" id="2_theme"]
[node name="PlayerHud" type="Control"]
layout_mode = 3
@@ -10,4 +11,5 @@ anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
theme = ExtResource("2_theme")
script = ExtResource("1")

View File

@@ -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 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 WebFilePicker = preload("res://_Core/utils/WebFilePicker.gd")
const TEXT_COLOR: Color = Color(0.188, 0.294, 0.424)
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 MOVEMENT_ACTIONS: Array[String] = ["move_left", "move_right", "move_up", "move_down"]
const AVATAR_NATIVE_FILE_FILTERS: Array[String] = ["*.png,*.jpg,*.jpeg,*.webp"]
const WEB_FILE_MAX_BYTES: int = 8 * 1024 * 1024
const CATEGORIES: Array[Dictionary] = [
{"id": "basic", "label": "基础", "icon": "basic"},
@@ -71,6 +73,7 @@ var _savedSettings: Dictionary = DEFAULT_SETTINGS.duplicate(true)
var _isOpen: bool = false
var _transitionTween: Tween
var _avatarUploadPending: bool = false
var _webFilePicker: RefCounted = WebFilePicker.new()
func _ready() -> void:
visible = false
@@ -976,6 +979,9 @@ func _on_reconnect_pressed() -> void:
_statusLabel.text = "已请求重新连接聊天服务"
func _on_upload_avatar_pressed() -> void:
if _try_open_web_avatar_file_picker():
_set_account_status("请选择头像图片")
return
if _open_macos_avatar_file_dialog():
return
if _try_open_native_avatar_file_picker():
@@ -983,6 +989,27 @@ func _on_upload_avatar_pressed() -> void:
return
_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:
if DisplayServer.get_name() == "headless":
return false

View 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)

View File

@@ -0,0 +1 @@
uid://22fxlhvp012i

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

View File

@@ -0,0 +1,532 @@
extends Control
## Lightweight public announcement HUD.
## This panel owns only the public feed; private conversations remain in ChatUI.
const PANEL_WIDTH := 560.0
const COLLAPSED_HEIGHT := 68.0
const EXPANDED_HEIGHT := 492.0
const EDGE_MARGIN := 26.0
const TEXT_COLOR := Color("24476f")
const MUTED_COLOR := Color("7894b2")
const ACCENT_COLOR := Color("2d94ed")
const SURFACE_COLOR := Color(0.985, 0.995, 1.0, 0.97)
const LINE_COLOR := Color("d9e8f7")
const MAX_ITEMS := 24
const ICON_EMPTY := preload("res://assets/ui/world_bulletin/community/empty_whale.png")
const ICON_BROADCAST := preload("res://assets/ui/world_bulletin/community/world_bulletin_icon_hd_simple_tight.png")
const ICON_BANNER := preload("res://assets/ui/world_bulletin/community/town_banner.png")
const ICON_PIN := preload("res://assets/ui/world_bulletin/community/pinned_note.png")
const ICON_RECRUIT := preload("res://assets/ui/world_bulletin/community/recruit_whales.png")
const ICON_SEND_BUTTON := preload("res://assets/ui/world_bulletin/world_bulletin_send_button_final_192.png")
var _panel: PanelContainer
var _surface_root: VBoxContainer
var _ticker_row: HBoxContainer
var _ticker: Button
var _expand_button: Button
var _content: VBoxContainer
var _header_title: Label
var _header_icon: TextureRect
var _header_banner: TextureRect
var _entries: VBoxContainer
var _scroll: ScrollContainer
var _composer: LineEdit
var _send_button: Button
var _expanded := false
var _announcements: Array[Dictionary] = []
var _unread := 0
var _last_preview := "暂无新的世界公告"
var _publish_pending := false
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
_build_ui()
_seed_preview_items()
_subscribe_to_events()
_set_expanded(true, true)
func _exit_tree() -> void:
var event_system := get_node_or_null("/root/EventSystem")
if event_system != null:
event_system.call("disconnect_event", EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
event_system.call("disconnect_event", EventNames.CHAT_MESSAGE_SENT, _on_chat_message_sent, self)
event_system.call("disconnect_event", EventNames.CHAT_ERROR_OCCURRED, _on_chat_error, self)
func _input(event: InputEvent) -> void:
if event is InputEventKey:
var key_event := event as InputEventKey
if key_event.pressed and not key_event.echo and key_event.keycode == KEY_B and not _composer.has_focus():
_set_expanded(not _expanded)
get_viewport().set_input_as_handled()
func _build_ui() -> void:
_panel = PanelContainer.new()
_panel.name = "WorldBulletinSurface"
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
_panel.add_theme_stylebox_override("panel", _surface_style())
add_child(_panel)
_panel.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
_panel.offset_left = EDGE_MARGIN
_panel.offset_top = -EDGE_MARGIN - COLLAPSED_HEIGHT
_panel.offset_right = EDGE_MARGIN + PANEL_WIDTH
_panel.offset_bottom = -EDGE_MARGIN
_panel.grow_horizontal = Control.GROW_DIRECTION_END
_panel.grow_vertical = Control.GROW_DIRECTION_BEGIN
_surface_root = VBoxContainer.new()
_surface_root.name = "SurfaceContent"
_surface_root.add_theme_constant_override("separation", 0)
_surface_root.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_surface_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
_panel.add_child(_surface_root)
_ticker_row = HBoxContainer.new()
_ticker_row.name = "CollapsedTickerRow"
_ticker_row.custom_minimum_size = Vector2(PANEL_WIDTH, COLLAPSED_HEIGHT)
_ticker_row.add_theme_constant_override("separation", 4)
_ticker_row.mouse_filter = Control.MOUSE_FILTER_IGNORE
_surface_root.add_child(_ticker_row)
_ticker = Button.new()
_ticker.name = "CollapsedTicker"
_ticker.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_ticker.custom_minimum_size = Vector2(0, COLLAPSED_HEIGHT)
_ticker.focus_mode = Control.FOCUS_NONE
_ticker.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
_ticker.alignment = HORIZONTAL_ALIGNMENT_LEFT
_ticker.add_theme_font_size_override("font_size", 20)
_ticker.add_theme_color_override("font_color", TEXT_COLOR)
_ticker.add_theme_color_override("font_hover_color", TEXT_COLOR)
_ticker.add_theme_stylebox_override("normal", _empty_style())
_ticker.add_theme_stylebox_override("hover", _hover_style())
_ticker.add_theme_stylebox_override("pressed", _pressed_style())
_ticker.pressed.connect(func() -> void: _set_expanded(not _expanded))
_ticker_row.add_child(_ticker)
_expand_button = Button.new()
_expand_button.name = "ExpandButton"
_expand_button.text = "展开"
_expand_button.custom_minimum_size = Vector2(72, COLLAPSED_HEIGHT)
_expand_button.focus_mode = Control.FOCUS_NONE
_expand_button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
_expand_button.add_theme_font_size_override("font_size", 18)
_expand_button.add_theme_color_override("font_color", ACCENT_COLOR)
_expand_button.add_theme_color_override("font_hover_color", TEXT_COLOR)
_expand_button.add_theme_stylebox_override("normal", _empty_style())
_expand_button.add_theme_stylebox_override("hover", _hover_style())
_expand_button.add_theme_stylebox_override("pressed", _pressed_style())
_expand_button.pressed.connect(func() -> void: _set_expanded(true))
_ticker_row.add_child(_expand_button)
_content = VBoxContainer.new()
_content.name = "ExpandedContent"
_content.add_theme_constant_override("separation", 0)
_content.mouse_filter = Control.MOUSE_FILTER_IGNORE
_surface_root.add_child(_content)
var header := HBoxContainer.new()
header.name = "Header"
header.custom_minimum_size = Vector2(0, 74)
header.add_theme_constant_override("separation", 12)
header.mouse_filter = Control.MOUSE_FILTER_IGNORE
_content.add_child(header)
_header_icon = TextureRect.new()
_header_icon.custom_minimum_size = Vector2(56, 56)
_header_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_header_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
_header_icon.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
_header_icon.texture = ICON_BROADCAST
_header_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
header.add_child(_header_icon)
_header_title = Label.new()
_header_title.text = "世界公告"
_header_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_header_title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_header_title.add_theme_font_size_override("font_size", 26)
_header_title.add_theme_color_override("font_color", TEXT_COLOR)
_header_title.mouse_filter = Control.MOUSE_FILTER_IGNORE
header.add_child(_header_title)
_header_banner = TextureRect.new()
_header_banner.name = "TownBanner"
_header_banner.custom_minimum_size = Vector2(88, 58)
_header_banner.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_header_banner.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
_header_banner.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
_header_banner.texture = ICON_BANNER
_header_banner.mouse_filter = Control.MOUSE_FILTER_IGNORE
header.add_child(_header_banner)
var close_button := Button.new()
close_button.text = "×"
close_button.tooltip_text = "收起世界公告"
close_button.custom_minimum_size = Vector2(54, 54)
close_button.focus_mode = Control.FOCUS_NONE
close_button.add_theme_font_size_override("font_size", 34)
close_button.add_theme_color_override("font_color", MUTED_COLOR)
close_button.add_theme_color_override("font_hover_color", ACCENT_COLOR)
close_button.add_theme_stylebox_override("normal", _empty_style())
close_button.add_theme_stylebox_override("hover", _hover_style())
close_button.add_theme_stylebox_override("pressed", _pressed_style())
close_button.pressed.connect(func() -> void: _set_expanded(false))
header.add_child(close_button)
var header_rule := ColorRect.new()
header_rule.custom_minimum_size = Vector2(0, 1)
header_rule.color = LINE_COLOR
header_rule.mouse_filter = Control.MOUSE_FILTER_IGNORE
_content.add_child(header_rule)
_scroll = ScrollContainer.new()
_scroll.name = "AnnouncementScroll"
_scroll.custom_minimum_size = Vector2(0, 320)
_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_scroll.mouse_filter = Control.MOUSE_FILTER_STOP
_content.add_child(_scroll)
_entries = VBoxContainer.new()
_entries.name = "AnnouncementEntries"
_entries.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_entries.add_theme_constant_override("separation", 0)
_scroll.add_child(_entries)
var composer_row := HBoxContainer.new()
composer_row.name = "Composer"
composer_row.custom_minimum_size = Vector2(0, 72)
composer_row.add_theme_constant_override("separation", 10)
_content.add_child(composer_row)
_composer = LineEdit.new()
_composer.name = "AnnouncementInput"
_composer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_composer.placeholder_text = "发布世界公告100鲸币..."
_composer.max_length = 160
_composer.add_theme_font_size_override("font_size", 20)
_composer.add_theme_color_override("font_color", TEXT_COLOR)
_composer.add_theme_color_override("font_placeholder_color", MUTED_COLOR)
_composer.add_theme_stylebox_override("normal", _input_style())
_composer.add_theme_stylebox_override("focus", _input_focus_style())
_composer.text_submitted.connect(func(_value: String) -> void: _publish())
composer_row.add_child(_composer)
_send_button = Button.new()
_send_button.name = "PublishButton"
_send_button.text = ""
_send_button.icon = ICON_SEND_BUTTON
_send_button.expand_icon = true
_send_button.icon_max_width = 56
_send_button.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
_send_button.tooltip_text = "发布世界公告"
_send_button.custom_minimum_size = Vector2(56, 56)
_send_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER
_send_button.focus_mode = Control.FOCUS_NONE
_send_button.add_theme_color_override("icon_normal_color", Color.WHITE)
_send_button.add_theme_color_override("icon_hover_color", Color("eef9ff"))
_send_button.add_theme_color_override("icon_pressed_color", Color("bedcf5"))
_send_button.add_theme_stylebox_override("normal", _empty_style())
_send_button.add_theme_stylebox_override("hover", _empty_style())
_send_button.add_theme_stylebox_override("pressed", _empty_style())
_send_button.add_theme_stylebox_override("focus", _empty_style())
_send_button.pressed.connect(_publish)
composer_row.add_child(_send_button)
func _subscribe_to_events() -> void:
var event_system := get_node_or_null("/root/EventSystem")
if event_system == null:
return
event_system.call("connect_event", EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
event_system.call("connect_event", EventNames.CHAT_MESSAGE_SENT, _on_chat_message_sent, self)
event_system.call("connect_event", EventNames.CHAT_ERROR_OCCURRED, _on_chat_error, self)
func _on_chat_message_sent(data: Dictionary) -> void:
if not _publish_pending or not bool(data.get("world_bulletin", false)):
return
_composer.clear()
_set_publish_pending(false)
func _on_chat_error(data: Dictionary) -> void:
if not _publish_pending or not bool(data.get("world_bulletin", false)):
return
_set_publish_pending(false)
_composer.grab_focus()
func _set_publish_pending(pending: bool) -> void:
_publish_pending = pending
_composer.editable = not pending
_send_button.disabled = pending
func _seed_preview_items() -> void:
_announcements = [
{"sender": "小海豚", "time": "12:08", "content": "今晚海湾烟花节 20:00 开始", "category": "活动", "pinned": true},
{"sender": "珊珊酱", "time": "12:12", "content": "需要 2 名采集伙伴一起出海", "category": "招募"},
{"sender": "海盐", "time": "12:20", "content": "新地图补给点已刷新", "category": "通知"},
{"sender": "系统", "time": "12:30", "content": "世界频道维护将在 15 分钟后结束", "category": "通知"}
]
_last_preview = str(_announcements[0].get("content", _last_preview))
_render_entries()
func _on_chat_message_received(data: Dictionary) -> void:
# 只展示后端确认并已扣费的世界公告,普通全局聊天不进公告栏。
if not bool(data.get("world_bulletin", data.get("worldBulletin", false))):
return
var scope := str(data.get("scope", data.get("channel", "global"))).to_lower()
var tab := str(data.get("tab", "world")).to_lower()
if scope in ["private", "whisper"] or tab in ["private", "whisper", "friends"]:
return
var content := str(data.get("content", data.get("message", ""))).strip_edges()
if content.is_empty():
return
var sender := str(data.get("from_user", data.get("username", "玩家"))).strip_edges()
if sender.is_empty():
sender = "玩家"
var category := "通知"
if sender == "系统" or str(data.get("sender_type", "")).to_lower() == "system":
category = "通知"
_announcements.push_front({
"sender": sender,
"time": Time.get_time_string_from_system().left(5),
"content": content,
"category": category
})
while _announcements.size() > MAX_ITEMS:
_announcements.pop_back()
_last_preview = content
if not _expanded:
_unread += 1
_render_entries()
_update_ticker()
func _set_expanded(expanded: bool, immediate := false) -> void:
_expanded = expanded
if _expanded:
_unread = 0
_panel.custom_minimum_size = Vector2(PANEL_WIDTH, EXPANDED_HEIGHT)
_panel.offset_top = -EDGE_MARGIN - EXPANDED_HEIGHT
_ticker_row.hide()
_content.show()
_render_entries()
else:
_panel.custom_minimum_size = Vector2(PANEL_WIDTH, COLLAPSED_HEIGHT)
_panel.offset_top = -EDGE_MARGIN - COLLAPSED_HEIGHT
_content.hide()
_ticker_row.show()
_update_ticker()
if immediate:
_panel.modulate.a = 1.0
func _update_ticker() -> void:
if not is_instance_valid(_ticker):
return
var unread_text := " · %d" % _unread if _unread > 0 else ""
_ticker.text = " 世界公告 %s%s" % [_last_preview.left(25), unread_text]
func _render_entries() -> void:
if not is_instance_valid(_entries):
return
for child in _entries.get_children():
child.queue_free()
if _announcements.is_empty():
var empty := VBoxContainer.new()
empty.custom_minimum_size = Vector2(0, 210)
empty.alignment = BoxContainer.ALIGNMENT_CENTER
_entries.add_child(empty)
var whale := TextureRect.new()
whale.texture = ICON_EMPTY
whale.custom_minimum_size = Vector2(0, 92)
whale.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
whale.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
whale.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
whale.mouse_filter = Control.MOUSE_FILTER_IGNORE
empty.add_child(whale)
var empty_label := Label.new()
empty_label.text = "暂无公告"
empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
empty_label.add_theme_color_override("font_color", MUTED_COLOR)
empty.add_child(empty_label)
return
for item in _announcements:
_entries.add_child(_build_entry(item))
func _build_entry(item: Dictionary) -> Control:
var card := PanelContainer.new()
card.custom_minimum_size = Vector2(0, 92)
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
card.mouse_filter = Control.MOUSE_FILTER_IGNORE
var card_style := StyleBoxFlat.new()
card_style.bg_color = Color(1, 1, 1, 0.55)
card_style.border_color = LINE_COLOR
card_style.set_border_width_all(1)
card_style.set_corner_radius_all(10)
card.add_theme_stylebox_override("panel", card_style)
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 10)
margin.add_theme_constant_override("margin_top", 7)
margin.add_theme_constant_override("margin_right", 10)
margin.add_theme_constant_override("margin_bottom", 7)
card.add_child(margin)
var card_row := HBoxContainer.new()
card_row.add_theme_constant_override("separation", 8)
card_row.mouse_filter = Control.MOUSE_FILTER_IGNORE
margin.add_child(card_row)
var row := VBoxContainer.new()
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_theme_constant_override("separation", 2)
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
card_row.add_child(row)
var meta := HBoxContainer.new()
meta.custom_minimum_size = Vector2(0, 30)
meta.add_theme_constant_override("separation", 6)
meta.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(meta)
if bool(item.get("pinned", false)):
var pin := TextureRect.new()
pin.custom_minimum_size = Vector2(28, 28)
pin.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
pin.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
pin.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
pin.texture = ICON_PIN
pin.tooltip_text = "置顶公告"
pin.mouse_filter = Control.MOUSE_FILTER_IGNORE
meta.add_child(pin)
var sender := Label.new()
sender.text = str(item.get("sender", "玩家"))
sender.add_theme_font_size_override("font_size", 20)
sender.add_theme_color_override("font_color", TEXT_COLOR)
meta.add_child(sender)
var category := Label.new()
category.text = str(item.get("category", "通知"))
category.add_theme_font_size_override("font_size", 16)
category.add_theme_color_override("font_color", Color.WHITE)
category.add_theme_stylebox_override("normal", _category_style(category.text))
meta.add_child(category)
if str(item.get("category", "")) == "招募":
var recruit := TextureRect.new()
recruit.custom_minimum_size = Vector2(32, 28)
recruit.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
recruit.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
recruit.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
recruit.texture = ICON_RECRUIT
recruit.mouse_filter = Control.MOUSE_FILTER_IGNORE
meta.add_child(recruit)
var time := Label.new()
time.text = str(item.get("time", ""))
time.size_flags_horizontal = Control.SIZE_EXPAND_FILL
time.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
time.add_theme_font_size_override("font_size", 16)
time.add_theme_color_override("font_color", MUTED_COLOR)
meta.add_child(time)
var body := Label.new()
body.text = str(item.get("content", ""))
body.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
body.custom_minimum_size = Vector2(0, 30)
body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
body.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
body.add_theme_font_size_override("font_size", 20)
body.add_theme_color_override("font_color", TEXT_COLOR)
body.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(body)
return card
func _category_style(category: String) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.set_corner_radius_all(6)
style.content_margin_left = 7
style.content_margin_right = 7
style.content_margin_top = 3
style.content_margin_bottom = 3
match category:
"活动": style.bg_color = Color("55bd8a")
"招募": style.bg_color = Color("f0aa4c")
_: style.bg_color = ACCENT_COLOR
return style
func _publish() -> void:
if _publish_pending:
return
var content := _composer.text.strip_edges()
if content.is_empty():
return
var manager := get_node_or_null("/root/ChatManager")
if manager == null or not manager.has_method("send_world_bulletin"):
return
if not bool(manager.call("send_world_bulletin", content)):
return
_set_publish_pending(true)
_set_expanded(true)
func _surface_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = SURFACE_COLOR
style.border_color = Color("b8d9f4")
style.set_border_width_all(2)
style.set_corner_radius_all(18)
style.shadow_color = Color(0.16, 0.40, 0.65, 0.18)
style.shadow_size = 10
style.shadow_offset = Vector2(0, 4)
style.content_margin_left = 14
style.content_margin_top = 12
style.content_margin_right = 14
style.content_margin_bottom = 12
return style
func _empty_style() -> StyleBoxEmpty:
return StyleBoxEmpty.new()
func _hover_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.88, 0.95, 1.0, 0.85)
style.set_corner_radius_all(12)
return style
func _pressed_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.78, 0.91, 1.0, 0.9)
style.set_corner_radius_all(12)
return style
func _input_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(1, 1, 1, 0.9)
style.border_color = Color("c4def4")
style.set_border_width_all(1)
style.set_corner_radius_all(12)
style.content_margin_left = 10
style.content_margin_right = 10
return style
func _input_focus_style() -> StyleBoxFlat:
var style := _input_style()
style.border_color = ACCENT_COLOR
style.set_border_width_all(2)
return style
func _accent_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = ACCENT_COLOR
style.set_corner_radius_all(14)
return style
func _accent_hover_style() -> StyleBoxFlat:
var style := _accent_style()
style.bg_color = Color("53a8f4")
return style
func _accent_pressed_style() -> StyleBoxFlat:
var style := _accent_style()
style.bg_color = Color("247bc7")
return style

View File

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

View File

@@ -0,0 +1,13 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scenes/ui/WorldBulletinPanel.gd" id="1_world_bulletin"]
[node name="WorldBulletinPanel" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1_world_bulletin")

79
scripts/build_desktop.sh Executable file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
GODOT_BIN="${GODOT_BIN:-/Applications/Godot.app/Contents/MacOS/Godot}"
TARGET="${1:-all}"
VERSION="${WHALETOWN_VERSION:-1.0.0}"
if [[ ! -x "$GODOT_BIN" ]]; then
echo "Godot executable not found: $GODOT_BIN" >&2
echo "Set GODOT_BIN to the Godot 4.6.2 executable path." >&2
exit 1
fi
export_target() {
local preset="$1"
local output="$2"
local marker
marker="$(mktemp /tmp/whaletown-export.XXXXXX)"
mkdir -p "$(dirname "$output")"
echo "Exporting $preset -> $output"
"$GODOT_BIN" --headless --path "$PROJECT_DIR" --export-release "$preset" "$output"
if [[ ! -e "$output" ]] || [[ -z "$(find "$output" -newer "$marker" -print -quit 2>/dev/null)" ]]; then
echo "Export failed: Godot did not create a fresh artifact at $output" >&2
exit 1
fi
}
package_artifacts() {
local release_dir="$PROJECT_DIR/build/desktop/release"
local mac_app="$PROJECT_DIR/build/desktop/macos/WhaleTown.app"
local windows_exe="$PROJECT_DIR/build/desktop/windows/WhaleTown.exe"
local mac_zip="$release_dir/WhaleTown-macOS-$VERSION.zip"
local windows_zip="$release_dir/WhaleTown-Windows-x86_64-$VERSION.zip"
if [[ ! -d "$mac_app" ]] || [[ ! -f "$windows_exe" ]]; then
echo "Both macOS and Windows artifacts are required before packaging." >&2
exit 1
fi
mkdir -p "$release_dir"
echo "Packaging $mac_zip"
/usr/bin/ditto -c -k --sequesterRsrc --keepParent "$mac_app" "$mac_zip"
echo "Packaging $windows_zip"
/usr/bin/ditto -c -k --keepParent "$windows_exe" "$windows_zip"
(
cd "$release_dir"
shasum -a 256 "$(basename "$mac_zip")" "$(basename "$windows_zip")" > SHA256SUMS.txt
)
echo "Release packages are under: $release_dir"
}
case "$TARGET" in
macos)
export_target "WhaleTown macOS" "$PROJECT_DIR/build/desktop/macos/WhaleTown.app"
;;
windows)
export_target "WhaleTown Windows" "$PROJECT_DIR/build/desktop/windows/WhaleTown.exe"
;;
all)
export_target "WhaleTown macOS" "$PROJECT_DIR/build/desktop/macos/WhaleTown.app"
export_target "WhaleTown Windows" "$PROJECT_DIR/build/desktop/windows/WhaleTown.exe"
;;
package)
package_artifacts
;;
release)
export_target "WhaleTown macOS" "$PROJECT_DIR/build/desktop/macos/WhaleTown.app"
export_target "WhaleTown Windows" "$PROJECT_DIR/build/desktop/windows/WhaleTown.exe"
package_artifacts
;;
*)
echo "Usage: $0 [macos|windows|all|package|release]" >&2
exit 2
;;
esac
echo "Desktop export complete. Files are under: $PROJECT_DIR/build/desktop"

View File

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

@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
SITE_DIR="$PROJECT_DIR/build/site"
WEB_DIR="$PROJECT_DIR/build/web"
HOMEPAGE_DIR="$(cd "$PROJECT_DIR/../homepage" && pwd)"
RELEASE_DIR="$PROJECT_DIR/build/desktop/release"
required_files=(
"$WEB_DIR/index.html"
"$WEB_DIR/index.js"
"$WEB_DIR/index.wasm"
"$RELEASE_DIR/WhaleTown-macOS-1.0.0.zip"
"$RELEASE_DIR/WhaleTown-Windows-x86_64-1.0.0.zip"
)
for file in "${required_files[@]}"; do
if [[ ! -f "$file" ]]; then
echo "Missing required release artifact: $file" >&2
echo "Build the Web and desktop releases before preparing the site." >&2
exit 1
fi
done
rm -rf "$SITE_DIR"
mkdir -p "$SITE_DIR/play" "$SITE_DIR/downloads"
cp -R "$HOMEPAGE_DIR/." "$SITE_DIR/"
if [[ -d "$SITE_DIR/.git" ]]; then
find "$SITE_DIR/.git" -depth -delete
fi
find "$SITE_DIR" -name '*.import' -delete
find "$SITE_DIR" -name '.DS_Store' -delete
cp -R "$WEB_DIR/." "$SITE_DIR/play/"
find "$SITE_DIR/play" -name '*.import' -delete
find "$SITE_DIR/play" -name '.DS_Store' -delete
cp "$RELEASE_DIR/WhaleTown-macOS-1.0.0.zip" "$SITE_DIR/downloads/"
cp "$RELEASE_DIR/WhaleTown-Windows-x86_64-1.0.0.zip" "$SITE_DIR/downloads/"
cp "$RELEASE_DIR/SHA256SUMS.txt" "$SITE_DIR/downloads/"
echo "Site release prepared in: $SITE_DIR"
echo " / Landing page"
echo " /play/ Web client"
echo " /downloads Desktop client ZIP files"

View File

@@ -0,0 +1,48 @@
extends SceneTree
const NETWORK_NPC_SCENE: PackedScene = preload("res://scenes/characters/network_npc.tscn")
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var npc := NETWORK_NPC_SCENE.instantiate()
root.add_child(npc)
npc.call("apply_snapshot", {
"npcId": "npc_niulai",
"name": "牛来",
"scene": "niulai_ambassador",
"version": 1,
"x": 0,
"y": 0,
"movementState": "idle",
})
var animationPlayer := npc.get_node("AnimationPlayer") as AnimationPlayer
assert(npc.collision_layer == 2, "NPC must occupy the dedicated NPC collision layer")
assert(npc.collision_mask == 1, "NPC collision mask must include static map collision")
var npcShape := (npc.get_node("CollisionShape2D") as CollisionShape2D).shape as RectangleShape2D
assert(npcShape.size == Vector2(52, 24), "Niulai must use the adjusted physical footprint")
var nameplate := npc.get_node("Nameplate") as Label
assert(nameplate.get_theme_font_size("font_size") == 16, "NPC names must use the player name font size")
assert(nameplate.get_theme_constant("outline_size") == 3, "NPC names must use the player name outline")
assert(nameplate.get_theme_stylebox("normal") is StyleBoxEmpty, "NPC names must not use the old pill background")
npc.call("_configure_visual", "town_mayor")
assert(is_equal_approx(npc.nameplateOffsetY, -52.0), "The mayor name must follow the visible propeller instead of transparent frame padding")
npc.call("_configure_visual", "niulai_ambassador")
var library := animationPlayer.get_animation_library("")
var expectedRows := {"down": 0, "up": 1, "right": 2, "left": 3}
for direction in expectedRows:
var animationName := "walk_%s" % direction
assert(library.has_animation(animationName), "missing %s" % animationName)
var animation := library.get_animation(animationName)
assert(animation.loop_mode == Animation.LOOP_LINEAR, "%s must loop" % animationName)
assert(animation.track_get_key_count(0) == 4, "%s must contain four frames" % animationName)
for column in range(4):
assert(
animation.track_get_key_value(0, column) == expectedRows[direction] * 4 + column,
"%s uses the wrong spritesheet row" % animationName,
)
npc.call("interact")
assert(root.get_node_or_null("WorldChatBubbleLayer") == null, "NPC interaction must not create a world bubble layer")
print("NETWORK_NPC_ANIMATIONS_OK")
quit()

View File

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

Some files were not shown because too many files have changed in this diff Show More