12 Commits

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
163 changed files with 6411 additions and 5194 deletions

13
.gitattributes vendored
View File

@@ -1,15 +1,2 @@
# Normalize EOL for all files that Git considers text files.
* text=auto eol=lf
# Large binary assets use Git LFS on their next modification. Existing history is
# intentionally not rewritten by this change.
*.png filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text
*.jpeg filter=lfs diff=lfs merge=lfs -text
*.webp filter=lfs diff=lfs merge=lfs -text
*.ttf filter=lfs diff=lfs merge=lfs -text
*.ttc filter=lfs diff=lfs merge=lfs -text
*.otf filter=lfs diff=lfs merge=lfs -text
*.wav filter=lfs diff=lfs merge=lfs -text
*.ogg filter=lfs diff=lfs merge=lfs -text
*.mp3 filter=lfs diff=lfs merge=lfs -text

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

@@ -16,17 +16,6 @@ WhaleTown V2 前端是基于 Godot 4.6 的 2D 多人小镇客户端。
2. 用 Godot 打开 `project.godot`
3. 运行默认主场景。
## 导出
仓库包含 Web 和 Linux 导出预设。安装 Godot 4.6 导出模板后可执行:
```bash
godot --headless --path . --export-release Web build/web/index.html
godot --headless --path . --export-release "Linux/X11" build/linux/WhaleTown-V2.x86_64
```
Web 产物是静态文件,必须通过 HTTP/HTTPS 服务器发布,不要直接用 `file://` 打开。
默认连接 WhaleTown 生产 API 和 WebSocket 服务。本地联调时可使用以下环境变量覆盖:
- `WHALETOWN_API_BASE_URL`

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"
@@ -82,8 +88,6 @@ const CHAT_LOGIN_FAILED = "chat_login_failed"
const CHAT_PRIVATE_TARGET_SELECTED = "chat_private_target_selected"
const CHAT_FRIEND_SELECTED = "chat_friend_selected"
const CHAT_FRIENDS_UPDATED = "chat_friends_updated"
const SOCIAL_NOTIFICATION_RECEIVED = "social_notification_received"
const SOCIAL_PROFILE_UPDATED = "social_profile_updated"
# ============================================================================
# 咖啡店陪伴机器人事件
@@ -107,8 +111,6 @@ const HUD_FRIEND_LIST_TOGGLE = "hud_friend_list_toggle"
const HUD_SETTINGS_REQUESTED = "hud_settings_requested"
const HUD_BACKPACK_TOGGLE = "hud_backpack_toggle"
const HUD_MAP_TOGGLE = "hud_map_toggle"
const HUD_TASK_BOOK_REQUESTED = "hud_task_book_requested"
const HUD_ADMIN_TEST_LAB_REQUESTED = "hud_admin_test_lab_requested"
# ============================================================================
# 商城事件

View File

@@ -1,54 +0,0 @@
extends RefCounted
# 统一转换后端聊天载荷,避免 WebSocket 实时消息和 HTTP 历史消息
# 各自维护一套时间戳兼容规则。
static func normalize_history(messages: Array, current_username: String) -> Array[Dictionary]:
var normalized: Array[Dictionary] = []
for message_variant: Variant in messages:
if not (message_variant is Dictionary):
continue
var message: Dictionary = message_variant
var sender := str(message.get("sender", message.get("from_user", "")))
normalized.append({
"from_user": sender,
"from_user_id": str(message.get("fromUserId", message.get("from_user_id", ""))),
"content": str(message.get("content", "")),
"timestamp": parse_timestamp(message.get("timestamp", 0.0)),
"is_self": sender == current_username,
"scope": str(message.get("scope", "local")),
"show_bubble": bool(message.get("bubble", false)),
"is_history": true,
})
return normalized
static func parse_timestamp(timestamp_raw: Variant) -> float:
if typeof(timestamp_raw) == TYPE_INT or typeof(timestamp_raw) == TYPE_FLOAT:
var numeric_timestamp := float(timestamp_raw)
return numeric_timestamp if numeric_timestamp > 0.0 else Time.get_unix_time_from_system()
var timestamp_text := str(timestamp_raw)
if timestamp_text.strip_edges().is_empty():
return Time.get_unix_time_from_system()
var numeric_regex := RegEx.new()
numeric_regex.compile("^\\s*-?\\d+(?:\\.\\d+)?\\s*$")
if numeric_regex.search(timestamp_text) != null:
var parsed_numeric := float(timestamp_text)
return parsed_numeric if parsed_numeric > 0.0 else Time.get_unix_time_from_system()
var iso_regex := RegEx.new()
iso_regex.compile("(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})")
var match_result := iso_regex.search(timestamp_text)
if match_result == null:
return Time.get_unix_time_from_system()
var utc_datetime := {
"year": int(match_result.get_string(1)),
"month": int(match_result.get_string(2)),
"day": int(match_result.get_string(3)),
"hour": int(match_result.get_string(4)),
"minute": int(match_result.get_string(5)),
"second": int(match_result.get_string(6)),
}
return Time.get_unix_time_from_datetime_dict(utc_datetime)

View File

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

View File

@@ -1,100 +0,0 @@
class_name InteractableComponent
extends Node
# 可挂载于任意 Node2D 宿主的交互组件。
# 它统一负责交互锚点、距离过滤、白圈位置和静态动作;复杂宿主可通过
# build_interaction_actions(component, player) 返回多个 InteractionAction。
const GROUP: StringName = &"whaletown_interactable_component"
@export_category("Interaction")
@export var interaction_id: String = ""
@export var interaction_title: String = ""
@export var interaction_priority: int = 100
@export var interaction_distance: float = 150.0
@export var activation_method: StringName = &""
@export var show_marker: bool = true
@export_category("Anchor")
@export var anchor_path: NodePath = NodePath("")
var _host: Node2D
func _ready() -> void:
_host = get_parent() as Node2D
if _host == null:
push_error("InteractableComponent 必须挂在 Node2D 宿主下:%s" % get_path())
return
add_to_group(GROUP)
func get_actions(player: Node2D) -> Array[InteractionAction]:
var actions: Array[InteractionAction] = []
if player == null or not is_interaction_active():
return actions
var distance: float = get_anchor_position().distance_to(player.global_position)
if distance > interaction_distance:
return actions
if _host != null and _host.has_method("build_interaction_actions"):
var actions_variant: Variant = _host.call("build_interaction_actions", self, player)
if actions_variant is Array:
for action_variant: Variant in actions_variant as Array:
if action_variant is InteractionAction:
var action: InteractionAction = action_variant as InteractionAction
if action.is_valid():
action.distance = distance
actions.append(action)
return actions
var default_action: InteractionAction = _create_default_action()
if default_action != null:
default_action.distance = distance
actions.append(default_action)
return actions
func get_marker_positions() -> Array[Vector2]:
if not show_marker or not is_interaction_active():
return []
return [get_anchor_position()]
func get_anchor_position() -> Vector2:
var anchor: Node2D = _resolve_anchor()
if anchor == null:
return Vector2.ZERO
var collision_shape: CollisionShape2D = _first_enabled_collision_shape(anchor)
return collision_shape.global_position if collision_shape != null else anchor.global_position
func is_interaction_active() -> bool:
if _host == null:
return false
if _host.has_method("is_interaction_active"):
return bool(_host.call("is_interaction_active", self))
return true
func _create_default_action() -> InteractionAction:
if _host == null or activation_method.is_empty() or interaction_id.strip_edges().is_empty() or interaction_title.strip_edges().is_empty():
return null
if not _host.has_method(activation_method):
push_warning("InteractableComponent: %s 不存在方法 %s" % [_host.get_path(), activation_method])
return null
return InteractionAction.create(interaction_id, interaction_title, interaction_priority, Callable(_host, activation_method))
func _resolve_anchor() -> Node2D:
if _host == null:
return null
if not anchor_path.is_empty():
var explicit_anchor: Node2D = _host.get_node_or_null(anchor_path) as Node2D
if explicit_anchor != null:
return explicit_anchor
return _host
func _first_enabled_collision_shape(anchor: Node) -> CollisionShape2D:
var direct_shape: CollisionShape2D = anchor as CollisionShape2D
if direct_shape != null and not direct_shape.disabled and direct_shape.shape != null:
return direct_shape
for child: Node in anchor.get_children():
var collision_shape: CollisionShape2D = child as CollisionShape2D
if collision_shape != null and not collision_shape.disabled and collision_shape.shape != null:
return collision_shape
for child: Node in anchor.get_children():
var nested_shape: CollisionShape2D = _first_enabled_collision_shape(child)
if nested_shape != null:
return nested_shape
return null

View File

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

View File

@@ -1,20 +0,0 @@
class_name InteractionAction
extends RefCounted
# 交互管理器使用的强类型动作数据,避免各交互物品以 Dictionary 约定字段。
var id: String = ""
var title: String = ""
var priority: int = 100
var distance: float = INF
var activate: Callable = Callable()
static func create(action_id: String, action_title: String, action_priority: int, callback: Callable) -> InteractionAction:
var action: InteractionAction = InteractionAction.new()
action.id = action_id
action.title = action_title
action.priority = action_priority
action.activate = callback
return action
func is_valid() -> bool:
return not id.strip_edges().is_empty() and not title.strip_edges().is_empty() and activate.is_valid()

View File

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

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,32 +30,33 @@ func _exit_tree() -> void:
request.cancel_request()
request.queue_free()
_activeRequests.clear()
_pendingAuthRetries.clear()
func get_json(endpoint: String, callback: Callable, authenticated: bool = true, timeout: float = REQUEST_TIMEOUT) -> void:
request_json(endpoint, {}, callback, HTTPClient.METHOD_GET, authenticated, timeout)
func get_json(endpoint: String, callback: Callable, authenticated: bool = true) -> void:
request_json(endpoint, {}, callback, HTTPClient.METHOD_GET, authenticated)
func post_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true, timeout: float = REQUEST_TIMEOUT) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_POST, authenticated, timeout)
func post_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_POST, authenticated)
func patch_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true, timeout: float = REQUEST_TIMEOUT) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_PATCH, authenticated, timeout)
func patch_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_PATCH, authenticated)
func put_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true, timeout: float = REQUEST_TIMEOUT) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_PUT, authenticated, timeout)
func put_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_PUT, authenticated)
func delete_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true, timeout: float = REQUEST_TIMEOUT) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_DELETE, authenticated, timeout)
func request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_GET, authenticated: bool = true) -> void:
_request_json(endpoint, payload, callback, method, authenticated, 0)
func request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_GET, authenticated: bool = true, timeout: float = REQUEST_TIMEOUT) -> void:
func _request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int, authenticated: bool, authRetryCount: int) -> void:
var request := HTTPRequest.new()
request.timeout = timeout
request.timeout = REQUEST_TIMEOUT
add_child(request)
_activeRequests.append(request)
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]
@@ -67,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,12 +125,66 @@ func _handle_response(endpoint: String, result: int, responseCode: int, body: Pa
"response_code": responseCode,
"error_code": str(response.get("error_code", ""))
}
for key in response.keys():
if not errorInfo.has(key):
errorInfo[key] = response[key]
request_failed.emit(endpoint, str(errorInfo.get("message", "请求失败")))
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:
if eventSystem != null:
eventSystem.call("connect_event", EventNames.AUTH_LOGOUT, _on_auth_logout, self)
var authManager := get_node_or_null("/root/AuthManager")
if authManager != null and authManager.has_signal("appearance_update_succeeded"):
authManager.connect("appearance_update_succeeded", _on_appearance_update_succeeded)
if authManager != null and authManager.has_signal("appearance_update_failed"):
authManager.connect("appearance_update_failed", _on_appearance_update_failed)
func _on_appearance_update_succeeded(requestedSkinId: String, profile: Dictionary) -> void:
if requestedSkinId != _pendingSkinId:
return
eventSystem.call("connect_event", EventNames.AUTH_LOGOUT, _on_auth_logout, self)
var confirmedSkinId := str(profile.get("skin_id", requestedSkinId)).strip_edges()
if confirmedSkinId.is_empty():
confirmedSkinId = requestedSkinId
_pendingSkinId = ""
_confirmedSkinId = confirmedSkinId
if selectedSkinId != confirmedSkinId:
_apply_selected_skin_locally(confirmedSkinId)
func _on_appearance_update_failed(requestedSkinId: String, _message: String) -> void:
if requestedSkinId != _pendingSkinId:
return
_pendingSkinId = ""
if not _confirmedSkinId.is_empty() and selectedSkinId == requestedSkinId:
_apply_selected_skin_locally(_confirmedSkinId)
func _apply_selected_skin_locally(skinId: String) -> void:
selectedSkinId = skinId
_save()
_emit_skin_changed()
_emit_profile_changed()
func _is_account_authenticated() -> bool:
var authManager := get_node_or_null("/root/AuthManager")
return authManager != null and authManager.has_method("is_authenticated") and bool(authManager.call("is_authenticated"))
func _on_auth_logout(_data: Variant = null) -> void:
_pendingSkinId = ""
_confirmedSkinId = FALLBACK_SKIN_ID
clear_account_profile()
func get_profile_payload() -> Dictionary:
@@ -762,11 +810,19 @@ func _get_or_create_avatar_texture_rect(panel: PanelContainer) -> TextureRect:
textureRect.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
textureRect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
textureRect.stretch_mode = TextureRect.STRETCH_SCALE
textureRect.material = _create_avatar_mask_material()
textureRect.custom_minimum_size = panel.custom_minimum_size
textureRect.set_anchors_preset(Control.PRESET_FULL_RECT)
panel.add_child(textureRect)
return textureRect
func _create_avatar_mask_material() -> ShaderMaterial:
var material := ShaderMaterial.new()
material.shader = AVATAR_MASK_SHADER
material.set_shader_parameter("corner_radius", 0.22)
material.set_shader_parameter("edge_feather", 0.008)
return material
func _normalize_account_skin_asset(skin: Dictionary) -> Dictionary:
var normalized := skin.duplicate(true)
var skinId := str(normalized.get("id", normalized.get("skin_id", ""))).strip_edges()
@@ -784,7 +840,7 @@ func _normalize_account_skin_asset(skin: Dictionary) -> Dictionary:
normalized["account_asset"] = true
return normalized
func _texture_from_base64(base64Text: String) -> Texture2D:
func _texture_from_base64(base64Text: String, roundAvatar: bool = false) -> Texture2D:
var bytes := Marshalls.base64_to_raw(base64Text.strip_edges())
if bytes.is_empty():
return null
@@ -792,6 +848,9 @@ func _texture_from_base64(base64Text: String) -> Texture2D:
var err := image.load_png_from_buffer(bytes)
if err != OK:
return null
if roundAvatar:
image = _create_square_avatar_image(image)
_apply_rounded_rect_alpha(image)
return ImageTexture.create_from_image(image)
func _absolute_backend_url(path: String) -> String:

View File

@@ -16,26 +16,64 @@ 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 SecureSessionStore = preload("res://_Core/security/SecureSessionStore.gd")
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const DEFAULT_AUTH_CONFIG_PATH: String = "user://auth.secure"
const LEGACY_AUTH_CONFIG_PATH: String = "user://auth.cfg"
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 = ""
var _current_user: Dictionary = {}
var _current_profile: Dictionary = {}
var _active_requests: Array[HTTPRequest] = []
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 _show_welcome_after_registration: bool = false
var _secure_session_store: RefCounted = SecureSessionStore.new()
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:
if is_instance_valid(request):
request.cancel_request()
request.queue_free()
_active_requests.clear()
func is_authenticated() -> bool:
return not _access_token.strip_edges().is_empty()
@@ -64,10 +102,13 @@ func get_account_generation() -> int:
func get_auth_config_path() -> String:
return _auth_config_path
func consume_registration_welcome() -> bool:
var should_show: bool = _show_welcome_after_registration
_show_welcome_after_registration = false
return should_show
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()
@@ -84,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
@@ -94,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)
@@ -102,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("请输入用户名")
@@ -114,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
@@ -137,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)
@@ -165,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", {})
@@ -195,41 +251,83 @@ 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()
_secure_session_store.call("clear", _auth_config_path)
if FileAccess.file_exists(_auth_config_path):
DirAccess.remove_absolute(_auth_config_path)
auth_state_changed.emit(false, {})
_emit_event(EventNames.AUTH_LOGOUT, {})
logout_completed.emit()
call_deferred("_return_to_auth_scene")
func _request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_POST, authenticated: bool = false, accountBound: bool = false) -> void:
var api_client := get_node_or_null("/root/ApiClient")
if api_client == null or not api_client.has_method("request_json"):
callback.call(false, {}, {"message": "ApiClient 不可用"})
return
var request_generation := _account_generation
api_client.call(
"request_json",
endpoint,
payload,
func(success: bool, response: Dictionary, error_info: Dictionary) -> void:
if accountBound and request_generation != _account_generation:
return
callback.call(success, response, error_info),
method,
authenticated
var request := HTTPRequest.new()
request.timeout = REQUEST_TIMEOUT
request.set_meta("account_bound", accountBound)
add_child(request)
_active_requests.append(request)
var requestGeneration := _account_generation
request.request_completed.connect(func(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
_active_requests.erase(request)
request.queue_free()
if accountBound and requestGeneration != _account_generation:
return
_handle_json_response(result, response_code, body, callback)
)
var url := "%s%s" % [NetworkConfig.get_api_base_url(), endpoint]
var headers := PackedStringArray(["Content-Type: application/json"])
if authenticated and not _access_token.strip_edges().is_empty():
headers.append("Authorization: Bearer %s" % _access_token)
var body := "" if method == HTTPClient.METHOD_GET else JSON.stringify(payload)
var err := request.request(url, headers, method, body)
if err != OK:
_active_requests.erase(request)
request.queue_free()
if not accountBound or requestGeneration == _account_generation:
callback.call(false, {}, {"message": "网络请求发送失败: %s" % error_string(err)})
func _handle_json_response(result: int, response_code: int, body: PackedByteArray, callback: Callable) -> void:
var body_text := body.get_string_from_utf8()
if result != HTTPRequest.RESULT_SUCCESS:
callback.call(false, {}, {"message": "网络请求失败: %s" % _http_result_to_string(result)})
return
var json := JSON.new()
if json.parse(body_text) != OK:
callback.call(false, {}, {"message": "服务器响应解析失败"})
return
var payload_variant: Variant = json.data
if not (payload_variant is Dictionary):
callback.call(false, {}, {"message": "服务器响应格式错误"})
return
var response: Dictionary = payload_variant
var success := response_code >= 200 and response_code < 300 and bool(response.get("success", true))
if success:
callback.call(true, response, {})
return
callback.call(false, response, {
"message": str(response.get("message", "请求失败")),
"response_code": response_code,
"error_code": str(response.get("error_code", ""))
})
func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary) -> void:
if not success:
login_failed.emit(str(error_info.get("message", "登录失败")))
@@ -249,15 +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
_show_welcome_after_registration = true
_clear_pending_registration()
_emit_event(EventNames.AUTH_REGISTER_SUCCESS, {
"user": get_current_user()
})
@@ -265,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()
@@ -283,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:
@@ -295,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", "玩家资料保存失败")))
@@ -314,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
@@ -369,38 +585,41 @@ func _refresh_player_snapshot() -> void:
playerStateManager.call_deferred("refresh_snapshot")
func _save_cached_session() -> void:
if _refresh_token.is_empty():
_secure_session_store.call("clear", _auth_config_path)
return
if not bool(_secure_session_store.call("save_refresh_token", _auth_config_path, _refresh_token)):
push_warning("AuthManager: 当前平台不支持安全持久化,将仅保留本次会话")
var config := ConfigFile.new()
config.set_value("auth", "refresh_token", _refresh_token)
config.set_value("auth", "access_token", _access_token)
config.set_value("auth", "saved_at", Time.get_unix_time_from_system())
var err := config.save(_auth_config_path)
if err != OK:
push_warning("AuthManager: 保存本地登录状态失败: %s" % error_string(err))
func _load_cached_session(emit_cached_state: bool = true) -> void:
_migrate_legacy_cached_session()
_refresh_token = str(_secure_session_store.call("load_refresh_token", _auth_config_path)).strip_edges()
_access_token = ""
if not FileAccess.file_exists(_auth_config_path):
return
var config := ConfigFile.new()
if config.load(_auth_config_path) != OK:
return
_refresh_token = str(config.get_value("auth", "refresh_token", "")).strip_edges()
_access_token = str(config.get_value("auth", "access_token", "")).strip_edges()
_access_token_expires_at = _jwt_expiration_time(_access_token)
_current_user.clear()
_current_profile.clear()
if _refresh_token.is_empty():
return
_session_generation += 1
_account_generation += 1
if emit_cached_state:
call_deferred("refresh_session")
func _migrate_legacy_cached_session() -> void:
if not FileAccess.file_exists(LEGACY_AUTH_CONFIG_PATH):
return
var config := ConfigFile.new()
if config.load(LEGACY_AUTH_CONFIG_PATH) == OK:
var legacy_refresh_token := str(config.get_value("auth", "refresh_token", "")).strip_edges()
if not legacy_refresh_token.is_empty():
_secure_session_store.call("save_refresh_token", _auth_config_path, legacy_refresh_token)
DirAccess.remove_absolute(ProjectSettings.globalize_path(LEGACY_AUTH_CONFIG_PATH))
if is_authenticated():
_session_generation += 1
_account_generation += 1
if emit_cached_state and is_authenticated():
call_deferred("_emit_cached_auth_state")
func _emit_cached_auth_state() -> void:
auth_state_changed.emit(true, get_current_user())
func _clear_session_memory() -> void:
_access_token = ""
_refresh_token = ""
_access_token_expires_at = 0.0
_current_user.clear()
_current_profile.clear()
_advance_account_generation()
@@ -409,6 +628,11 @@ func _advance_account_generation() -> void:
_session_generation += 1
_account_generation += 1
_refresh_in_flight = false
for request in _active_requests.duplicate():
if is_instance_valid(request) and bool(request.get_meta("account_bound", false)):
request.cancel_request()
request.queue_free()
_active_requests.erase(request)
func _return_to_auth_scene() -> void:
var tree := get_tree()
@@ -451,3 +675,40 @@ func _password_has_letter_and_number(password: String) -> bool:
if code >= 48 and code <= 57:
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:
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

View File

@@ -16,8 +16,11 @@ signal cafe_companion_agent_registered(data: Dictionary)
signal cafe_companion_employment_resigned(data: Dictionary)
signal cafe_companion_models_ready(data: Dictionary)
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const REQUEST_TIMEOUT: float = 24.0
var _activeRequests: Array[HTTPRequest] = []
var _cachedProducts: Array = []
var _cachedServicePoints: Array = []
var _accountGeneration: int = -1
@@ -28,6 +31,9 @@ func _ready() -> void:
if authManager != null and authManager.has_signal("auth_state_changed"):
authManager.auth_state_changed.connect(_on_auth_state_changed)
func _exit_tree() -> void:
_cancel_active_requests()
func get_chat_time_products(forceRefresh: bool = false) -> void:
if not forceRefresh and not _cachedProducts.is_empty():
var cachedData := {"products": _cachedProducts}
@@ -108,15 +114,56 @@ func get_employment_models(protocol: String, baseUrl: String, token: String) ->
}, _on_employment_models_response)
func _request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_POST) -> void:
var request_generation := _current_account_generation()
ApiClient.request_json(endpoint, payload, func(success: bool, response: Dictionary, error_info: Dictionary) -> void:
if request_generation != _current_account_generation():
var request := HTTPRequest.new()
request.timeout = REQUEST_TIMEOUT
add_child(request)
_activeRequests.append(request)
var requestGeneration := _current_account_generation()
request.request_completed.connect(func(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
_activeRequests.erase(request)
request.queue_free()
if requestGeneration != _current_account_generation():
return
if not success:
_emit_chat_error(str(error_info.get("message", "咖啡店陪聊请求失败")))
return
callback.call(response)
, method, true, REQUEST_TIMEOUT)
_handle_json_response(result, responseCode, body, callback)
)
var headers := PackedStringArray(["Content-Type: application/json"])
var token := _get_auth_token()
if not token.is_empty():
headers.append("Authorization: Bearer %s" % token)
var body := "" if method == HTTPClient.METHOD_GET else JSON.stringify(payload)
var err := request.request("%s%s" % [NetworkConfig.get_api_base_url(), endpoint], headers, method, body)
if err != OK:
_activeRequests.erase(request)
request.queue_free()
if requestGeneration == _current_account_generation():
_emit_chat_error("咖啡店陪聊请求发送失败: %s" % error_string(err))
func _handle_json_response(result: int, responseCode: int, body: PackedByteArray, callback: Callable) -> void:
if result != HTTPRequest.RESULT_SUCCESS:
_emit_chat_error("咖啡店陪聊网络请求失败: %s" % _http_result_to_string(result))
return
var bodyText := body.get_string_from_utf8()
var json := JSON.new()
if json.parse(bodyText) != OK:
_emit_chat_error("咖啡店陪聊响应解析失败")
return
var payloadVariant: Variant = json.data
if not (payloadVariant is Dictionary):
_emit_chat_error("咖啡店陪聊响应格式错误")
return
var response: Dictionary = payloadVariant
var success := responseCode >= 200 and responseCode < 300 and bool(response.get("success", true))
if not success:
_emit_chat_error(str(response.get("message", "咖啡店陪聊请求失败")))
return
callback.call(response)
func _on_chat_time_products_response(response: Dictionary) -> void:
var data := _response_data(response)
@@ -210,5 +257,30 @@ func _on_auth_state_changed(_isAuthenticated: bool, _user: Dictionary) -> void:
if currentGeneration == _accountGeneration:
return
_accountGeneration = currentGeneration
_cancel_active_requests()
_cachedProducts.clear()
_cachedServicePoints.clear()
func _cancel_active_requests() -> void:
for request in _activeRequests.duplicate():
if is_instance_valid(request):
request.cancel_request()
request.queue_free()
_activeRequests.clear()
func _http_result_to_string(result: int) -> String:
match result:
HTTPRequest.RESULT_SUCCESS:
return "SUCCESS"
HTTPRequest.RESULT_TIMEOUT:
return "TIMEOUT"
HTTPRequest.RESULT_CANT_CONNECT:
return "CANT_CONNECT"
HTTPRequest.RESULT_CANT_RESOLVE:
return "CANT_RESOLVE"
HTTPRequest.RESULT_CONNECTION_ERROR:
return "CONNECTION_ERROR"
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
return "TLS_HANDSHAKE_ERROR"
_:
return "HTTP_RESULT_%d" % result

View File

@@ -63,11 +63,16 @@ signal chat_position_updated(stream: String, topic: String)
# ============================================================================
const CHAT_WEBSOCKET_MANAGER_SCRIPT: Script = preload("res://_Core/managers/WebSocketManager.gd")
const ChatMessageCodec = preload("res://_Core/chat/ChatMessageCodec.gd")
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
# WebSocket 服务器 URL原生 WebSocket
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
@@ -95,7 +100,9 @@ const CHAT_ERROR_MESSAGES: Dictionary = {
"PERMISSION_DENIED": "您没有权限发送消息",
"SESSION_EXPIRED": "会话已过期,请重新连接",
"ZULIP_ERROR": "消息服务暂时不可用",
"INTERNAL_ERROR": "服务器内部错误"
"INTERNAL_ERROR": "服务器内部错误",
"INSUFFICIENT_BALANCE": "鲸币余额不足,发布世界公告需要 100 鲸币",
"WALLET_UNAVAILABLE": "鲸币服务暂不可用,请稍后再试"
}
# ============================================================================
@@ -104,6 +111,7 @@ const CHAT_ERROR_MESSAGES: Dictionary = {
# WebSocket 管理器
var _websocket_manager: Node
var _history_request: HTTPRequest
# 是否已登录
var _is_logged_in: bool = false
@@ -115,18 +123,24 @@ var _message_history: Array[Dictionary] = []
var _history_loading: bool = false
var _has_more_history: bool = true
var _oldest_message_timestamp: float = 0.0
var _history_request_generation: int = 0
# 消息发送时间戳(用于频率限制)
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] = []
@@ -157,6 +171,14 @@ func _ready() -> void:
# 创建 WebSocket 管理器
_websocket_manager = CHAT_WEBSOCKET_MANAGER_SCRIPT.new()
add_child(_websocket_manager)
_history_request = HTTPRequest.new()
_history_request.timeout = 12.0
_history_request.request_completed.connect(_on_history_request_completed)
add_child(_history_request)
_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()
@@ -202,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 := {
@@ -216,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():
@@ -232,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
#
@@ -260,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
@@ -284,6 +370,7 @@ func disconnect_from_chat_server() -> void:
# 断开连接
_websocket_manager.disconnect_websocket()
_guest_mode = false
# 检查是否已连接
#
@@ -318,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", "附近私聊已在设置中关闭")
@@ -442,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")
@@ -494,11 +625,12 @@ func _send_chat_payload(content: String, scope: String = "local", extra_data: Di
if send_err != OK:
_handle_error("SEND_FAILED", "WebSocket send failed: %s" % error_string(send_err))
return false
_track_recoverable_message({
"content": normalized_content,
"scope": scope,
"extra_data": recoverable_extra_data
})
if not non_recoverable:
_track_recoverable_message({
"content": normalized_content,
"scope": scope,
"extra_data": recoverable_extra_data
})
# 记录发送时间
if not is_recovery_retry:
@@ -568,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
@@ -582,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)
@@ -597,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()
# 清理过期的时间戳
@@ -654,7 +793,6 @@ func clear_message_history() -> void:
# - 用户登录成功后
# - 重新连接到聊天服务器后
func reset_session() -> void:
_history_request_generation += 1
_message_history.clear()
_history_loading = false
_has_more_history = true
@@ -689,31 +827,59 @@ func load_history(count: int = HISTORY_PAGE_SIZE) -> void:
var mapId := _current_map.strip_edges()
if mapId.is_empty():
mapId = "whale_port"
var endpoint := "/chat/history?mapId=%s&limit=%d&offset=%d" % [
var url := "%s/chat/history?mapId=%s&limit=%d&offset=%d" % [
NetworkConfig.get_api_base_url(),
mapId.uri_encode(),
max(1, count),
_message_history.size(),
]
var request_generation := _history_request_generation
ApiClient.get_json(endpoint, _on_history_request_completed.bind(request_generation))
func _on_history_request_completed(success: bool, response: Dictionary, error_info: Dictionary, request_generation: int) -> void:
if request_generation != _history_request_generation:
return
if not success:
var err := _history_request.request(url, PackedStringArray([
"Accept: application/json",
"Authorization: Bearer %s" % token,
]), HTTPClient.METHOD_GET)
if err != OK:
_history_loading = false
_handle_error("INTERNAL_ERROR", str(error_info.get("message", "聊天历史读取失败")))
_handle_error("INTERNAL_ERROR", "聊天历史请求发送失败")
func _on_history_request_completed(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300:
_history_loading = false
_handle_error("INTERNAL_ERROR", "聊天历史读取失败")
return
var json := JSON.new()
if json.parse(body.get_string_from_utf8()) != OK or not (json.data is Dictionary):
_history_loading = false
_handle_error("INTERNAL_ERROR", "聊天历史响应解析失败")
return
var response: Dictionary = json.data
var messagesVariant: Variant = response.get("messages", [])
var dataVariant: Variant = response.get("data", {})
if messagesVariant is Array and (messagesVariant as Array).is_empty() and dataVariant is Dictionary:
messagesVariant = (dataVariant as Dictionary).get("messages", [])
if messagesVariant is Array:
_on_history_loaded(ChatMessageCodec.normalize_history(messagesVariant as Array, _current_username))
_on_history_loaded(_normalize_history_messages(messagesVariant as Array))
return
_history_loading = false
_has_more_history = false
func _normalize_history_messages(messages: Array) -> Array:
var normalized: Array = []
for messageVariant in messages:
if not (messageVariant is Dictionary):
continue
var message: Dictionary = messageVariant
normalized.append({
"from_user": str(message.get("sender", message.get("from_user", ""))),
"from_user_id": str(message.get("fromUserId", message.get("from_user_id", ""))),
"content": str(message.get("content", "")),
"timestamp": _parse_chat_timestamp_to_unix(message.get("timestamp", 0.0)),
"is_self": str(message.get("sender", "")) == _current_username,
"scope": str(message.get("scope", "local")),
"show_bubble": bool(message.get("bubble", false)),
"is_history": true,
})
return normalized
# 历史消息加载完成回调
func _on_history_loaded(messages: Array) -> void:
_history_loading = false
@@ -760,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:
@@ -877,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
@@ -933,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":
@@ -945,12 +1120,6 @@ func _on_data_received(message: String) -> void:
_handle_chat_error(data)
"chat_render":
_handle_chat_render(data)
"dm_message":
_handle_direct_message(data)
"dm_read":
_emit_event(EventNames.SOCIAL_NOTIFICATION_RECEIVED, {"type": "dm_read", "data": data})
"notification_created", "friendship_changed", "friend_presence_changed":
_emit_event(EventNames.SOCIAL_NOTIFICATION_RECEIVED, {"type": message_type, "data": data})
"friend_list":
_handle_friend_list(data)
"friend_added":
@@ -969,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":
@@ -978,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":
@@ -1003,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()
@@ -1023,11 +1244,23 @@ 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():
return
var timestamp := ChatMessageCodec.parse_timestamp(data.get("timestamp", 0.0))
var timestamp := _parse_chat_timestamp_to_unix(data.get("timestamp", 0.0))
var message := {
"from_user": "系统",
"from_user_id": "",
@@ -1042,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:
@@ -1083,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)
@@ -1090,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()
@@ -1119,8 +1369,7 @@ func _handle_friend_list(data: Dictionary) -> void:
_friends.append({
"user_id": str(friend.get("userId", friend.get("user_id", ""))).strip_edges(),
"username": str(friend.get("username", "")),
"online": bool(friend.get("online", false)),
"room_visitable": bool(friend.get("room_visitable", false))
"online": bool(friend.get("online", false))
})
_emit_event(EventNames.CHAT_FRIENDS_UPDATED, {
@@ -1139,8 +1388,7 @@ func _handle_friend_added(data: Dictionary) -> void:
_upsert_friend({
"user_id": friend_user_id,
"username": str(friend.get("username", "")),
"online": bool(friend.get("online", false)),
"room_visitable": bool(friend.get("room_visitable", false))
"online": bool(friend.get("online", false))
})
request_friend_list()
@@ -1230,8 +1478,9 @@ 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 = ChatMessageCodec.parse_timestamp(data.get("timestamp", 0.0))
var timestamp: float = _parse_chat_timestamp_to_unix(data.get("timestamp", 0.0))
var is_self: bool = (not _current_username.is_empty() and from_user == _current_username)
if is_self and _consume_pending_self_message(content, scope, to_user_id):
@@ -1254,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
})
# 发射信号
@@ -1272,43 +1522,43 @@ 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
})
func _handle_direct_message(data: Dictionary) -> void:
var message_variant: Variant = data.get("message", {})
if not (message_variant is Dictionary):
return
var message: Dictionary = message_variant
var sender_variant: Variant = message.get("sender", {})
var sender: Dictionary = sender_variant if sender_variant is Dictionary else {}
var sender_id := str(message.get("senderId", "")).strip_edges()
var recipient_id := str(message.get("recipientId", "")).strip_edges()
var from_user := str(sender.get("nickname", sender.get("username", "玩家"))).strip_edges()
var is_self := sender_id == _current_user_id()
var payload := {
"from": from_user,
"fromUserId": sender_id,
"txt": str(message.get("content", "")),
"timestamp": message.get("createdAt", Time.get_unix_time_from_system()),
"scope": "private",
"toUserId": recipient_id,
"toUsername": "",
"privateContext": "dm",
"bubble": false,
}
if is_self and _consume_pending_self_message(str(payload.get("txt", "")), "private", recipient_id):
return
_handle_chat_render(payload)
_emit_event(EventNames.SOCIAL_NOTIFICATION_RECEIVED, {"type": "dm_message", "data": data})
# 解析聊天消息时间戳(兼容 unix 秒 / ISO 8601 字符串)
func _parse_chat_timestamp_to_unix(timestamp_raw: Variant) -> float:
if typeof(timestamp_raw) == TYPE_INT or typeof(timestamp_raw) == TYPE_FLOAT:
var ts := float(timestamp_raw)
return ts if ts > 0.0 else Time.get_unix_time_from_system()
func _current_user_id() -> String:
var authManager := get_node_or_null("/root/AuthManager")
if authManager != null and authManager.has_method("get_current_user"):
var user_variant: Variant = authManager.call("get_current_user")
if user_variant is Dictionary:
return str((user_variant as Dictionary).get("id", ""))
return ""
var ts_str := str(timestamp_raw)
if ts_str.strip_edges().is_empty():
return Time.get_unix_time_from_system()
# 纯数字字符串(必须整串都是数字/小数点,避免把 ISO 字符串前缀 "2026" 误判成时间戳)
var numeric_regex := RegEx.new()
numeric_regex.compile("^\\s*-?\\d+(?:\\.\\d+)?\\s*$")
if numeric_regex.search(ts_str) != null:
var ts_num := float(ts_str)
return ts_num if ts_num > 0.0 else Time.get_unix_time_from_system()
# ISO 8601: 2026-01-19T15:15:43.930Z
var regex := RegEx.new()
regex.compile("(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})")
var result := regex.search(ts_str)
if result == null:
return Time.get_unix_time_from_system()
var utc_dict := {
"year": int(result.get_string(1)),
"month": int(result.get_string(2)),
"day": int(result.get_string(3)),
"hour": int(result.get_string(4)),
"minute": int(result.get_string(5)),
"second": int(result.get_string(6))
}
return Time.get_unix_time_from_datetime_dict(utc_dict)
# 处理位置更新成功
func _handle_position_updated(data: Dictionary) -> void:
@@ -1334,13 +1584,16 @@ func _handle_position_update(data: Dictionary) -> void:
"userId": user_id,
"username": str(data.get("username", "")),
"position": Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))),
"mapId": str(data.get("mapId", data.get("map_id", ""))),
"skin_id": str(appearance.get("skin_id", "")),
"avatar_id": str(appearance.get("avatar_id", "")),
"skin_asset": appearance.get("skin_asset", {}),
"cafe_companion": _normalize_cafe_companion(data.get("cafeCompanion", data.get("cafe_companion", null))),
"movement_locked": bool(data.get("movementLocked", data.get("movement_locked", false)))
})
"direction": str(data.get("direction", "down")),
"movement_state": str(data.get("movementState", data.get("movement_state", "walk"))),
"sequence": int(data.get("sequence", -1)),
"mapId": str(data.get("mapId", data.get("map_id", ""))),
"skin_id": str(appearance.get("skin_id", "")),
"avatar_id": str(appearance.get("avatar_id", "")),
"skin_asset": appearance.get("skin_asset", {}),
"cafe_companion": _normalize_cafe_companion(data.get("cafeCompanion", data.get("cafe_companion", null))),
"movement_locked": bool(data.get("movementLocked", data.get("movement_locked", false)))
})
func _handle_map_players_snapshot(data: Dictionary) -> void:
var players: Array[Dictionary] = []
@@ -1358,19 +1611,88 @@ func _handle_map_players_snapshot(data: Dictionary) -> void:
"userId": user_id,
"username": str(player.get("username", "")),
"position": Vector2(float(player.get("x", 0.0)), float(player.get("y", 0.0))),
"mapId": str(player.get("mapId", player.get("map_id", data.get("mapId", data.get("map_id", ""))))),
"skin_id": str(appearance.get("skin_id", "")),
"avatar_id": str(appearance.get("avatar_id", "")),
"skin_asset": appearance.get("skin_asset", {}),
"cafe_companion": _normalize_cafe_companion(player.get("cafeCompanion", player.get("cafe_companion", null))),
"movement_locked": bool(player.get("movementLocked", player.get("movement_locked", false)))
})
"direction": str(player.get("direction", "down")),
"movement_state": str(player.get("movementState", player.get("movement_state", "idle"))),
"sequence": int(player.get("sequence", -1)),
"mapId": str(player.get("mapId", player.get("map_id", data.get("mapId", data.get("map_id", ""))))),
"skin_id": str(appearance.get("skin_id", "")),
"avatar_id": str(appearance.get("avatar_id", "")),
"skin_asset": appearance.get("skin_asset", {}),
"cafe_companion": _normalize_cafe_companion(player.get("cafeCompanion", player.get("cafe_companion", null))),
"movement_locked": bool(player.get("movementLocked", player.get("movement_locked", false)))
})
_emit_event(EventNames.REMOTE_PLAYERS_SNAPSHOT_READY, {
"mapId": str(data.get("mapId", data.get("map_id", ""))),
"players": players
})
func _handle_npc_snapshot(data: Dictionary) -> void:
var npcs: Array[Dictionary] = []
var npcsVariant: Variant = data.get("npcs", [])
if npcsVariant is Array:
for npcVariant in npcsVariant:
if not (npcVariant is Dictionary):
continue
var npc: Dictionary = npcVariant
var npcId := str(npc.get("npcId", npc.get("npc_id", ""))).strip_edges()
if npcId.is_empty():
continue
npcs.append({
"npc_id": npcId,
"name": str(npc.get("name", "NPC")),
"x": float(npc.get("x", 0.0)),
"y": float(npc.get("y", 0.0)),
"direction": str(npc.get("direction", "down")),
"movement_state": str(npc.get("movementState", npc.get("movement_state", "idle"))),
"state": str(npc.get("state", "idle")),
"version": int(npc.get("version", 0)),
"daily_goal": str(npc.get("dailyGoal", npc.get("daily_goal", ""))),
"plan_source": str(npc.get("planSource", npc.get("plan_source", "fallback"))),
"current_activity": npc.get("currentActivity", npc.get("current_activity", {})),
"public_intention": str(npc.get("publicIntention", npc.get("public_intention", ""))),
"dialogue": str(npc.get("dialogue", "")),
"scene": str(npc.get("scene", "classic_whale")),
"active_action": _normalize_npc_action(npc.get("activeAction", npc.get("active_action", null))),
})
_emit_event(EventNames.NPC_SNAPSHOT_READY, {
"mapId": str(data.get("mapId", data.get("map_id", ""))),
"server_now": int(data.get("serverNow", data.get("server_now", 0))),
"version": int(data.get("version", 0)),
"npcs": npcs,
})
func _normalize_npc_action_event(data: Dictionary) -> Dictionary:
return {
"mapId": str(data.get("mapId", data.get("map_id", ""))),
"server_now": int(data.get("serverNow", data.get("server_now", 0))),
"npc_id": str(data.get("npcId", data.get("npc_id", ""))),
"action": _normalize_npc_action(data.get("action", {})),
}
func _normalize_npc_action(value: Variant) -> Dictionary:
if not (value is Dictionary):
return {}
var action: Dictionary = value
return {
"action_id": str(action.get("actionId", action.get("action_id", ""))),
"kind": str(action.get("kind", "walk")),
"from_x": float(action.get("fromX", action.get("from_x", 0.0))),
"from_y": float(action.get("fromY", action.get("from_y", 0.0))),
"to_x": float(action.get("toX", action.get("to_x", 0.0))),
"to_y": float(action.get("toY", action.get("to_y", 0.0))),
"from_map_id": str(action.get("fromMapId", action.get("from_map_id", ""))),
"to_map_id": str(action.get("toMapId", action.get("to_map_id", ""))),
"from_location_id": str(action.get("fromLocationId", action.get("from_location_id", ""))),
"to_location_id": str(action.get("toLocationId", action.get("to_location_id", ""))),
"activity_id": str(action.get("activityId", action.get("activity_id", ""))),
"activity_kind": str(action.get("activityKind", action.get("activity_kind", ""))),
"started_at": int(action.get("startedAt", action.get("started_at", 0))),
"completes_at": int(action.get("completesAt", action.get("completes_at", 0))),
"version": int(action.get("version", 0)),
}
func _handle_player_joined(data: Dictionary) -> void:
var user_id := str(data.get("userId", data.get("user_id", ""))).strip_edges()
if user_id.is_empty():
@@ -1381,19 +1703,25 @@ func _handle_player_joined(data: Dictionary) -> void:
"userId": user_id,
"username": str(data.get("username", "")),
"position": Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))),
"mapId": str(data.get("mapId", data.get("map_id", ""))),
"skin_id": str(appearance.get("skin_id", "")),
"avatar_id": str(appearance.get("avatar_id", "")),
"skin_asset": appearance.get("skin_asset", {}),
"cafe_companion": _normalize_cafe_companion(data.get("cafeCompanion", data.get("cafe_companion", null))),
"movement_locked": bool(data.get("movementLocked", data.get("movement_locked", false)))
})
"direction": str(data.get("direction", "down")),
"movement_state": str(data.get("movementState", data.get("movement_state", "idle"))),
"sequence": int(data.get("sequence", -1)),
"mapId": str(data.get("mapId", data.get("map_id", ""))),
"skin_id": str(appearance.get("skin_id", "")),
"avatar_id": str(appearance.get("avatar_id", "")),
"skin_asset": appearance.get("skin_asset", {}),
"cafe_companion": _normalize_cafe_companion(data.get("cafeCompanion", data.get("cafe_companion", null))),
"movement_locked": bool(data.get("movementLocked", data.get("movement_locked", false)))
})
func _normalize_player_appearance(data: Dictionary) -> Dictionary:
var appearanceVariant: Variant = data.get("appearance", {})
var appearance: Dictionary = appearanceVariant if appearanceVariant is Dictionary else {}
var skinId := str(data.get("skinId", data.get("skin_id", appearance.get("skinId", appearance.get("skin_id", ""))))).strip_edges()
if skinId.is_empty() or skinId == "pending_initial_skin":
skinId = "classic_whale"
return {
"skin_id": str(data.get("skinId", data.get("skin_id", appearance.get("skinId", appearance.get("skin_id", ""))))),
"skin_id": skinId,
"avatar_id": str(data.get("avatarId", data.get("avatar_id", appearance.get("avatarId", appearance.get("avatar_id", ""))))),
"skin_asset": data.get("skinAsset", data.get("skin_asset", appearance.get("skinAsset", appearance.get("skin_asset", {})))),
}
@@ -1486,14 +1814,12 @@ func _normalize_friend_requests(requests_variant: Variant) -> Array[Dictionary]:
if not (request_variant is Dictionary):
continue
var request: Dictionary = request_variant
var requester_variant: Variant = request.get("requester", {})
var requester: Dictionary = requester_variant if requester_variant is Dictionary else {}
var user_id := str(request.get("userId", request.get("user_id", requester.get("id", "")))).strip_edges()
var user_id := str(request.get("userId", request.get("user_id", ""))).strip_edges()
if user_id.is_empty():
continue
requests.append({
"user_id": user_id,
"username": str(request.get("username", requester.get("nickname", requester.get("username", "玩家")))).strip_edges(),
"username": str(request.get("username", "玩家")).strip_edges(),
"created_at": str(request.get("createdAt", request.get("created_at", ""))).strip_edges()
})
return requests

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

@@ -1,245 +0,0 @@
extends Node
# 全地图统一交互收集角色附近的动作通过方向键选择、E 执行。
const SCAN_INTERVAL: float = 0.10
const MAX_ACTIONS_VISIBLE: int = 5
const ACCENT: Color = Color("58c7db")
const PANEL_COLOR: Color = Color(0.035, 0.071, 0.106, 0.94)
const INTERACTION_POINT_MARKERS_SCRIPT: Script = preload("res://_Core/ui/InteractionPointMarkers.gd")
var _actions: Array[InteractionAction] = []
var _selected_index: int = 0
var _last_selected_id: String = ""
var _scan_elapsed: float = SCAN_INTERVAL
var _canvas: CanvasLayer
var _panel: PanelContainer
var _list: VBoxContainer
var _hint: Label
var _executing: bool = false
var _pointMarkers: Node2D
func _ready() -> void:
_build_hud()
func _process(delta: float) -> void:
_scan_elapsed += delta
if _scan_elapsed < SCAN_INTERVAL:
return
_scan_elapsed = 0.0
_refresh_actions()
func _unhandled_input(event: InputEvent) -> void:
if _actions.is_empty() or _is_text_input_focused():
return
if not (event is InputEventKey):
return
var key_event := event as InputEventKey
if not key_event.pressed or key_event.echo:
return
if key_event.keycode == KEY_UP:
_select_offset(-1)
get_viewport().set_input_as_handled()
elif key_event.keycode == KEY_DOWN:
_select_offset(1)
get_viewport().set_input_as_handled()
elif key_event.keycode == KEY_E:
_execute_selected()
get_viewport().set_input_as_handled()
func is_selection_active() -> bool:
return not _actions.is_empty() and not _is_text_input_focused()
func _refresh_actions() -> void:
if _is_text_input_focused() or SceneManager.is_changing_scene:
_set_actions([])
return
var player := _local_player()
if player == null:
_set_actions([])
return
var collected: Array[InteractionAction] = []
for node: Node in get_tree().get_nodes_in_group(InteractableComponent.GROUP):
var interactable: InteractableComponent = node as InteractableComponent
if interactable == null:
continue
for action: InteractionAction in interactable.get_actions(player):
collected.append(action)
collected.sort_custom(func(a: InteractionAction, b: InteractionAction) -> bool:
if a.priority != b.priority:
return a.priority < b.priority
if not is_equal_approx(a.distance, b.distance):
return a.distance < b.distance
return a.title < b.title
)
_set_actions(collected)
func _set_actions(next_actions: Array[InteractionAction]) -> void:
_actions = next_actions
if _actions.is_empty():
_selected_index = 0
_last_selected_id = ""
_render()
return
var matched_index := -1
for index in _actions.size():
if _actions[index].id == _last_selected_id:
matched_index = index
break
_selected_index = matched_index if matched_index >= 0 else clampi(_selected_index, 0, _actions.size() - 1)
_last_selected_id = _actions[_selected_index].id
_render()
func _select_offset(offset: int) -> void:
if _actions.is_empty():
return
_selected_index = posmod(_selected_index + offset, _actions.size())
_last_selected_id = _actions[_selected_index].id
_render()
func _execute_selected() -> void:
if _executing or _actions.is_empty():
return
var action: InteractionAction = _actions[_selected_index]
if not action.activate.is_valid():
return
_executing = true
_render()
action.activate.call()
await get_tree().create_timer(0.18).timeout
_executing = false
_refresh_actions()
func _local_player() -> Node2D:
var players := get_tree().get_nodes_in_group("whaletown_local_player")
return players.front() as Node2D if not players.is_empty() else null
func _build_hud() -> void:
_canvas = CanvasLayer.new()
_canvas.layer = 90
add_child(_canvas)
_panel = PanelContainer.new()
_panel.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
_panel.position = Vector2(-250, -282)
_panel.size = Vector2(500, 214)
_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_panel.add_theme_stylebox_override("panel", _panel_style())
_canvas.add_child(_panel)
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 14)
margin.add_theme_constant_override("margin_top", 10)
margin.add_theme_constant_override("margin_right", 14)
margin.add_theme_constant_override("margin_bottom", 10)
_panel.add_child(margin)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 4)
margin.add_child(content)
_list = VBoxContainer.new()
_list.add_theme_constant_override("separation", 2)
content.add_child(_list)
_hint = Label.new()
_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_hint.add_theme_color_override("font_color", Color(0.67, 0.79, 0.85, 1.0))
_hint.add_theme_font_size_override("font_size", 14)
_hint.text = "[↑/↓] 选择 [E] 交互"
content.add_child(_hint)
_render()
func _render() -> void:
if not is_instance_valid(_panel):
return
var show_hints: bool = _should_show_interaction_hints()
_panel.visible = show_hints and not _actions.is_empty()
_render_interaction_points()
for child in _list.get_children():
child.queue_free()
if _actions.is_empty():
return
var start: int = clampi(_selected_index - 2, 0, maxi(0, _actions.size() - MAX_ACTIONS_VISIBLE))
var finish: int = mini(_actions.size(), start + MAX_ACTIONS_VISIBLE)
for index in range(start, finish):
var action: InteractionAction = _actions[index]
var row := Label.new()
row.custom_minimum_size = Vector2(0, 29)
row.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
row.add_theme_font_size_override("font_size", 16)
var selected := index == _selected_index
row.text = (" " if selected else " ") + action.title + ("" if selected and _executing else "")
row.add_theme_color_override("font_color", ACCENT if selected else Color(0.92, 0.96, 0.98, 1.0))
if selected:
row.add_theme_stylebox_override("normal", _selected_style())
_list.add_child(row)
_hint.text = "[↑/↓] 选择 [E] 交互 [Esc] 关闭" + (" %d" % _actions.size() if _actions.size() > 1 else "")
func _render_interaction_points() -> void:
if not _should_show_interaction_points():
if is_instance_valid(_pointMarkers):
var empty_points: Array[Vector2] = []
_pointMarkers.call("set_points", empty_points)
return
var markers: Node2D = _ensure_point_markers()
if markers == null:
return
var positions: Array[Vector2] = []
for node: Node in get_tree().get_nodes_in_group(InteractableComponent.GROUP):
var interactable: InteractableComponent = node as InteractableComponent
if interactable == null:
continue
for position: Vector2 in interactable.get_marker_positions():
positions.append(position)
markers.call("set_points", positions)
func _ensure_point_markers() -> Node2D:
var current_scene: Node = get_tree().current_scene
var world_root: Node2D = current_scene as Node2D
if world_root == null:
return null
if is_instance_valid(_pointMarkers) and _pointMarkers.get_parent() == world_root:
return _pointMarkers
var markers: Node2D = INTERACTION_POINT_MARKERS_SCRIPT.new() as Node2D
if markers == null:
return null
markers.name = "InteractionPointMarkers"
markers.z_index = 4096
markers.z_as_relative = false
world_root.add_child(markers)
_pointMarkers = markers
return _pointMarkers
func _should_show_interaction_hints() -> bool:
return _get_setting_enabled("show_interaction_hints", true)
func _should_show_interaction_points() -> bool:
return _get_setting_enabled("show_interaction_points", false)
func _get_setting_enabled(setting_key: String, fallback: bool) -> bool:
var settings_manager: Node = get_node_or_null("/root/SettingsManager")
if settings_manager != null and settings_manager.has_method("get_bool"):
return bool(settings_manager.call("get_bool", setting_key))
return fallback
func _panel_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = PANEL_COLOR
style.border_color = Color(0.20, 0.45, 0.54, 0.86)
style.set_border_width_all(1)
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.shadow_color = Color(0, 0, 0, 0.35)
style.shadow_size = 10
return style
func _selected_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.12, 0.32, 0.38, 0.74)
style.corner_radius_top_left = 6
style.corner_radius_top_right = 6
style.corner_radius_bottom_left = 6
style.corner_radius_bottom_right = 6
style.content_margin_left = 6
return style
func _is_text_input_focused() -> bool:
var focus_owner := get_viewport().gui_get_focus_owner()
return focus_owner is LineEdit or focus_owner is TextEdit

View File

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

View File

@@ -2,188 +2,182 @@ extends Node
signal decor_save_succeeded(item: Dictionary)
signal decor_save_failed(item: Dictionary, message: String)
signal decor_revision_conflict(current_revision: int, message: String)
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const REQUEST_TIMEOUT: float = 12.0
const MAX_SAVE_RETRIES: int = 3
const RETRY_BASE_DELAY: float = 0.75
var _retry_timer: Timer
var _request: HTTPRequest
var _retryTimer: Timer
var _queue: Array[Dictionary] = []
var _in_flight: Dictionary = {}
var _account_generation: int = -1
var _request_serial: int = 0
var _confirmed_revision: int = 0
var _inFlight: Dictionary = {}
var _requestGeneration: int = -1
var _accountGeneration: int = -1
func _ready() -> void:
_retry_timer = Timer.new()
_retry_timer.name = "roomDecorSaveRetryTimer"
_retry_timer.one_shot = true
_retry_timer.timeout.connect(_process_next_save)
add_child(_retry_timer)
_request = HTTPRequest.new()
_request.name = "roomDecorSaveRequest"
_request.timeout = REQUEST_TIMEOUT
_request.request_completed.connect(_on_request_completed)
add_child(_request)
var auth_manager := get_node_or_null("/root/AuthManager")
if auth_manager != null and auth_manager.has_signal("auth_state_changed"):
_account_generation = _current_account_generation()
auth_manager.auth_state_changed.connect(_on_auth_state_changed)
_retryTimer = Timer.new()
_retryTimer.name = "roomDecorSaveRetryTimer"
_retryTimer.one_shot = true
_retryTimer.timeout.connect(_process_next_save)
add_child(_retryTimer)
func set_layout_revision(revision: int, clear_pending: bool = true) -> void:
if clear_pending:
clear_pending_saves()
_confirmed_revision = maxi(0, revision)
func get_layout_revision() -> int:
return _confirmed_revision
func clear_pending_saves() -> void:
_request_serial += 1
if is_instance_valid(_retry_timer):
_retry_timer.stop()
_queue.clear()
_in_flight.clear()
var authManager := get_node_or_null("/root/AuthManager")
if authManager != null and authManager.has_signal("auth_state_changed"):
_accountGeneration = _current_account_generation()
authManager.auth_state_changed.connect(_on_auth_state_changed)
func enqueue_save(item: Dictionary) -> bool:
if not _is_authenticated():
decor_save_failed.emit(item.duplicate(true), "请先登录后保存摆放")
return false
var decor_id := str(item.get("decor_id", "")).strip_edges()
if decor_id.is_empty():
var decorId := str(item.get("decor_id", "")).strip_edges()
if decorId.is_empty():
decor_save_failed.emit(item.duplicate(true), "家具数据缺少 decor_id")
return false
# Only the newest queued placement for a decor matters.
for index in range(_queue.size() - 1, -1, -1):
var queued_item: Dictionary = _queue[index].get("item", {})
if str(queued_item.get("decor_id", "")) == decor_id:
var queuedItem: Dictionary = _queue[index].get("item", {})
if str(queuedItem.get("decor_id", "")) == decorId:
_queue.remove_at(index)
_queue.append({
"item": item.duplicate(true),
"retry_count": 0,
"mutation_id": _create_mutation_id(),
})
_queue.append({"item": item.duplicate(true), "retry_count": 0})
_process_next_save()
return true
func has_pending_saves() -> bool:
return not _in_flight.is_empty() or not _queue.is_empty() or (_retry_timer != null and not _retry_timer.is_stopped())
return not _inFlight.is_empty() or not _queue.is_empty() or (_retryTimer != null and not _retryTimer.is_stopped())
func _process_next_save() -> void:
if not _in_flight.is_empty() or _queue.is_empty() or not _is_authenticated():
if not _inFlight.is_empty() or _queue.is_empty() or not _is_authenticated():
return
if _retry_timer != null and not _retry_timer.is_stopped():
if _retryTimer != null and not _retryTimer.is_stopped():
return
_in_flight = _queue.pop_front()
if not _in_flight.has("layout_revision"):
_in_flight["layout_revision"] = _confirmed_revision
_in_flight["mutation_revision"] = _confirmed_revision + 1
var item: Dictionary = _in_flight.get("item", {})
_inFlight = _queue.pop_front()
_requestGeneration = _current_account_generation()
var item: Dictionary = _inFlight.get("item", {})
var payload := {
"decor_id": str(item.get("decor_id", "")),
"placed": bool(item.get("placed", false)),
"position_x": float(item.get("position_x", 0.0)),
"position_y": float(item.get("position_y", 0.0)),
"scale": float(item.get("scale", item.get("default_scale", 1.0))),
"rotation_degrees": int(item.get("rotation_degrees", item.get("default_rotation_degrees", 0))),
"z_index": int(item.get("z_index", item.get("default_z_index", 0))),
"layout_revision": int(_in_flight.get("layout_revision", _confirmed_revision)),
"mutation_revision": int(_in_flight.get("mutation_revision", _confirmed_revision + 1)),
"mutation_id": str(_in_flight.get("mutation_id", "")),
}
var api_client := get_node_or_null("/root/ApiClient")
if api_client == null or not api_client.has_method("put_json"):
_retry_or_fail("家具位置保存服务不可用")
return
_request_serial += 1
var serial := _request_serial
var generation := _current_account_generation()
var decor_id := str(item.get("decor_id", "")).uri_encode()
api_client.call(
"put_json",
"/rooms/me/decor-placements/%s" % decor_id,
payload,
Callable(self, "_on_save_response").bind(serial, generation),
true
var decorId := str(item.get("decor_id", "")).uri_encode()
var err := _request.request(
"%s/rooms/me/decor-placements/%s" % [NetworkConfig.get_api_base_url(), decorId],
_auth_headers(),
HTTPClient.METHOD_PUT,
JSON.stringify(payload)
)
if err != OK:
_retry_or_fail("家具位置保存请求发送失败")
func _on_save_response(
success: bool,
response: Dictionary,
error_info: Dictionary,
serial: int,
generation: int
) -> void:
if serial != _request_serial or _in_flight.is_empty():
func _on_request_completed(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
if _inFlight.is_empty():
return
if generation != _current_account_generation():
clear_pending_saves()
if _requestGeneration != _current_account_generation():
_inFlight.clear()
_requestGeneration = -1
_process_next_save()
return
if not success:
var message := str(error_info.get("message", "家具位置保存失败"))
if int(error_info.get("response_code", 0)) == 409:
_handle_revision_conflict(error_info, message)
if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300:
_retry_or_fail(_read_error_message(body, "家具位置保存失败"))
return
var savedItem: Dictionary = (_inFlight.get("item", {}) as Dictionary).duplicate(true)
var json := JSON.new()
if json.parse(body.get_string_from_utf8()) == OK and json.data is Dictionary:
var response: Dictionary = json.data
if not bool(response.get("success", true)):
_retry_or_fail(str(response.get("message", "家具位置保存失败")))
return
_retry_or_fail(message)
return
var dataVariant: Variant = response.get("data", {})
if dataVariant is Dictionary:
savedItem = (dataVariant as Dictionary).duplicate(true)
var saved_item: Dictionary = (_in_flight.get("item", {}) as Dictionary).duplicate(true)
var data_variant: Variant = response.get("data", {})
if data_variant is Dictionary:
saved_item = (data_variant as Dictionary).duplicate(true)
var accepted_revision := int(saved_item.get("layout_revision", _in_flight.get("mutation_revision", _confirmed_revision)))
_confirmed_revision = maxi(_confirmed_revision, accepted_revision)
_in_flight.clear()
decor_save_succeeded.emit(saved_item)
_inFlight.clear()
_requestGeneration = -1
decor_save_succeeded.emit(savedItem)
_process_next_save()
func _retry_or_fail(message: String) -> void:
if _in_flight.is_empty():
if _inFlight.is_empty():
return
var failed_entry := _in_flight.duplicate(true)
var failed_item: Dictionary = failed_entry.get("item", {})
_in_flight.clear()
var failedEntry := _inFlight.duplicate(true)
var failedItem: Dictionary = failedEntry.get("item", {})
var decorId := str(failedItem.get("decor_id", ""))
_inFlight.clear()
_requestGeneration = -1
var retry_count := int(failed_entry.get("retry_count", 0)) + 1
if retry_count <= MAX_SAVE_RETRIES and _is_authenticated():
failed_entry["retry_count"] = retry_count
_queue.push_front(failed_entry)
_retry_timer.start(RETRY_BASE_DELAY * pow(2.0, retry_count - 1))
if _queue_has_decor(decorId):
_process_next_save()
return
var retryCount := int(failedEntry.get("retry_count", 0)) + 1
if retryCount <= MAX_SAVE_RETRIES and _is_authenticated():
failedEntry["retry_count"] = retryCount
_queue.push_front(failedEntry)
_retryTimer.start(RETRY_BASE_DELAY * pow(2.0, retryCount - 1))
return
decor_save_failed.emit(failed_item.duplicate(true), message)
_fail_queued_entries("前序装修写入失败,请重新保存")
decor_save_failed.emit(failedItem.duplicate(true), message)
_process_next_save()
func _handle_revision_conflict(error_info: Dictionary, message: String) -> void:
var current_revision := int(error_info.get("current_revision", _confirmed_revision))
var failed_item: Dictionary = (_in_flight.get("item", {}) as Dictionary).duplicate(true)
_in_flight.clear()
decor_save_failed.emit(failed_item, message)
_fail_queued_entries(message)
_confirmed_revision = maxi(_confirmed_revision, current_revision)
decor_revision_conflict.emit(current_revision, message)
func _queue_has_decor(decorId: String) -> bool:
for entryVariant in _queue:
if entryVariant is Dictionary:
var item: Dictionary = (entryVariant as Dictionary).get("item", {})
if str(item.get("decor_id", "")) == decorId:
return true
return false
func _fail_queued_entries(message: String) -> void:
for entry in _queue:
var item_variant: Variant = entry.get("item", {})
if item_variant is Dictionary:
decor_save_failed.emit((item_variant as Dictionary).duplicate(true), message)
func _on_auth_state_changed(_isAuthenticated: bool, _user: Dictionary) -> void:
var currentGeneration := _current_account_generation()
if currentGeneration == _accountGeneration:
return
_accountGeneration = currentGeneration
if is_instance_valid(_request):
_request.cancel_request()
if is_instance_valid(_retryTimer):
_retryTimer.stop()
_queue.clear()
if is_instance_valid(_retry_timer):
_retry_timer.stop()
_inFlight.clear()
_requestGeneration = -1
func _on_auth_state_changed(_is_authenticated_value: bool, _user: Dictionary) -> void:
var current_generation := _current_account_generation()
if current_generation == _account_generation:
return
_account_generation = current_generation
clear_pending_saves()
_confirmed_revision = 0
func _auth_headers() -> PackedStringArray:
var authManager := get_node_or_null("/root/AuthManager")
var accessToken := str(authManager.call("get_access_token")).strip_edges() if authManager != null and authManager.has_method("get_access_token") else ""
return PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % accessToken,
])
func _is_authenticated() -> bool:
var auth_manager := get_node_or_null("/root/AuthManager")
return auth_manager != null and auth_manager.has_method("is_authenticated") and bool(auth_manager.call("is_authenticated"))
var authManager := get_node_or_null("/root/AuthManager")
return authManager != null and authManager.has_method("is_authenticated") and bool(authManager.call("is_authenticated"))
func _current_account_generation() -> int:
var auth_manager := get_node_or_null("/root/AuthManager")
return int(auth_manager.call("get_account_generation")) if auth_manager != null and auth_manager.has_method("get_account_generation") else -1
var authManager := get_node_or_null("/root/AuthManager")
return int(authManager.call("get_account_generation")) if authManager != null and authManager.has_method("get_account_generation") else -1
func _create_mutation_id() -> String:
return Crypto.new().generate_random_bytes(16).hex_encode()
func _read_error_message(body: PackedByteArray, fallback: String) -> String:
var json := JSON.new()
if json.parse(body.get_string_from_utf8()) != OK or not (json.data is Dictionary):
return fallback
var response: Dictionary = json.data
var messageVariant: Variant = response.get("message", fallback)
if messageVariant is Array:
var parts: Array[String] = []
for part in messageVariant:
parts.append(str(part))
return "; ".join(parts) if not parts.is_empty() else fallback
var message := str(messageVariant).strip_edges()
return message if not message.is_empty() else fallback

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,8 +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 _next_destination_id: String = "" # 快速传送目标,用于发现登记
var _room_visit_context: Dictionary = {} # 访客房间的房主与返回位置
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"],
}
# 场景路径映射表
# 将场景名称映射到实际的文件路径
@@ -103,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:
@@ -114,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:
@@ -121,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)]
# ============ 查询方法 ============
# 获取当前场景名称
@@ -196,47 +504,6 @@ func get_next_spawn_name() -> String:
_next_spawn_name = ""
return spawn_name
func set_next_destination_id(destination_id: String) -> void:
_next_destination_id = destination_id.strip_edges()
func get_next_destination_id() -> String:
var destination_id := _next_destination_id
_next_destination_id = ""
return destination_id
# ============ 访客房间上下文 ============
func begin_room_visit(owner_user_id: String, owner_nickname: String, return_scene: String, return_position: Vector2) -> void:
_room_visit_context = {
"owner_user_id": owner_user_id.strip_edges(),
"owner_nickname": owner_nickname.strip_edges(),
"return_scene": return_scene.strip_edges(),
"return_position": return_position,
}
func get_room_visit_context() -> Dictionary:
return _room_visit_context.duplicate(true)
func has_room_visit_context() -> bool:
return not str(_room_visit_context.get("owner_user_id", "")).strip_edges().is_empty()
func clear_room_visit_context() -> void:
_room_visit_context.clear()
func return_from_room_visit() -> bool:
if not has_room_visit_context():
return false
var context: Dictionary = _room_visit_context.duplicate(true)
clear_room_visit_context()
var return_scene: String = str(context.get("return_scene", "square")).strip_edges()
if not scene_paths.has(return_scene):
return_scene = "square"
var return_position: Variant = context.get("return_position", null)
if return_position is Vector2:
set_next_scene_position(return_position as Vector2)
change_scene(return_scene)
return true
# ============ 场景注册方法 ============
# 注册新场景

View File

@@ -15,8 +15,7 @@ const DEFAULT_SETTINGS: Dictionary = {
"effects_volume": 0.90,
"ui_scale": 1.00,
"fullscreen": false,
"show_interaction_hints": true,
"show_interaction_points": false,
"show_interaction_hints": false,
"show_name_always": false,
"show_chat_bubbles": true,
"world_notifications": true,
@@ -24,8 +23,6 @@ const DEFAULT_SETTINGS: Dictionary = {
"friend_request_notifications": true,
"allow_nearby_private": true,
"allow_nearby_friend_requests": true,
"allow_nearby_profile": true,
"room_visit_policy": "friends",
"mute_ui_sfx": false,
}

View File

@@ -1,480 +0,0 @@
extends Node
# 社区资料、通知与旅行 API 的客户端协调器。
const ACCENT := Color("58c7db")
const PANEL_COLOR := Color(0.035, 0.071, 0.106, 0.97)
const DISCOVERY_DISTANCE: float = 180.0
const DISCOVERY_POINTS: Array[Dictionary] = [
{"id": "square_center", "mapId": "whale_port", "position": Vector2(0, 30)},
{"id": "square_dock", "mapId": "whale_port", "position": Vector2(-870, -202)},
{"id": "square_headquarters", "mapId": "whale_port", "position": Vector2(13, -600)},
{"id": "square_cottage", "mapId": "whale_port", "position": Vector2(845, -192)},
{"id": "square_workshop", "mapId": "whale_port", "position": Vector2(645, 500)},
{"id": "square_notice", "mapId": "whale_port", "position": Vector2(-542, 648)},
{"id": "square_work_zone_gate", "mapId": "whale_port", "position": Vector2(0, 775)},
{"id": "work_entrance", "mapId": "work_zone", "position": Vector2(0, 755)},
{"id": "work_mall", "mapId": "work_zone", "position": Vector2(0, -614)},
{"id": "work_cafe_gate", "mapId": "work_zone", "position": Vector2(-1044, 222)},
{"id": "work_jobs", "mapId": "work_zone", "position": Vector2(-502, 192)},
{"id": "work_courses", "mapId": "work_zone", "position": Vector2(496, 0)},
{"id": "work_ai", "mapId": "work_zone", "position": Vector2(452, 548)},
{"id": "work_exchange", "mapId": "work_zone", "position": Vector2(1075, 548)},
{"id": "cafe_entrance", "mapId": "whale_cafe", "position": Vector2(0, 363)},
{"id": "cafe_counter", "mapId": "whale_cafe", "position": Vector2(0, -37)},
{"id": "cafe_companion", "mapId": "whale_cafe", "position": Vector2(-398, -226)},
{"id": "personal_room", "mapId": "personal_space", "position": Vector2.ZERO},
]
var _canvas: CanvasLayer
var _profile_panel: PanelContainer
var _profile_content: VBoxContainer
var _notification_panel: PanelContainer
var _notification_content: VBoxContainer
var _current_profile: Dictionary = {}
var _discoveryRequests: Dictionary = {}
var _discoveredDestinations: Dictionary = {}
func _ready() -> void:
add_to_group("whaletown_escape_dismissible")
_build_ui()
var event_system := get_node_or_null("/root/EventSystem")
if event_system != null:
event_system.call("connect_event", EventNames.SOCIAL_NOTIFICATION_RECEIVED, _on_social_realtime, self)
func _exit_tree() -> void:
var event_system := get_node_or_null("/root/EventSystem")
if event_system != null:
event_system.call("disconnect_event", EventNames.SOCIAL_NOTIFICATION_RECEIVED, _on_social_realtime, self)
func show_profile(user_id: String) -> void:
var normalized := user_id.strip_edges()
if normalized.is_empty():
return
_request_get("/social/profiles/%s" % normalized, func(success: bool, response: Dictionary, error: Dictionary) -> void:
if not success:
_show_status("无法查看名片:%s" % str(error.get("message", "请求失败")))
return
var data_variant: Variant = response.get("data", {})
if data_variant is Dictionary:
_current_profile = data_variant as Dictionary
_render_profile()
)
func show_own_profile() -> void:
_request_get("/social/profile", func(success: bool, response: Dictionary, error: Dictionary) -> void:
if not success:
_show_status("无法读取个人名片:%s" % str(error.get("message", "请求失败")))
return
var data_variant: Variant = response.get("data", {})
if data_variant is Dictionary:
_current_profile = data_variant as Dictionary
_render_profile()
)
func request_friend(user_id: String, username: String = "") -> void:
var chat := get_node_or_null("/root/ChatManager")
if chat != null and chat.has_method("request_friend"):
chat.call("request_friend", user_id, username)
func open_private_chat(user_id: String, username: String) -> void:
var event_system := get_node_or_null("/root/EventSystem")
if event_system != null:
event_system.call("emit_event", EventNames.CHAT_PRIVATE_TARGET_SELECTED, {"userId": user_id, "username": username})
_close_profile()
func visit_room(user_id: String, nickname: String = "") -> void:
var owner_id: String = user_id.strip_edges()
if owner_id.is_empty():
return
_request_get("/social/profiles/%s" % owner_id.uri_encode(), func(success: bool, response: Dictionary, error: Dictionary) -> void:
if not success:
_show_status("无法访问房间:%s" % str(error.get("message", "请求失败")))
return
var profile_variant: Variant = response.get("data", {})
if not (profile_variant is Dictionary) or not bool((profile_variant as Dictionary).get("room_visitable", false)):
_show_status("该玩家当前不开放个人空间访问")
return
var profile: Dictionary = profile_variant as Dictionary
_begin_room_visit(owner_id, str(profile.get("nickname", nickname)))
)
func _begin_room_visit(owner_id: String, nickname: String) -> void:
var current_scene: Node = get_tree().current_scene
var return_scene: String = SceneManager.get_current_scene_name().strip_edges()
if return_scene.is_empty() and current_scene != null:
return_scene = _scene_name_to_id(current_scene.name)
if return_scene.is_empty() or return_scene == "personal_space":
return_scene = "square"
var return_position := Vector2.ZERO
if current_scene != null:
var player_node := current_scene.get_node_or_null("YSortWorld/Characters/Players/Player") as Node2D
if player_node != null:
return_position = player_node.global_position
SceneManager.begin_room_visit(owner_id, nickname, return_scene, return_position)
_close_profile()
SceneManager.change_scene("personal_space")
func request_travel(destination_id: String, completion: Callable = Callable()) -> void:
_request_post("/world/travel-destinations/%s/travel" % destination_id.uri_encode(), {}, func(success: bool, response: Dictionary, error: Dictionary) -> void:
if not success:
_show_status(str(error.get("message", "该地点尚未解锁")))
if completion.is_valid(): completion.call(false, {})
return
var data_variant: Variant = response.get("data", {})
var data: Dictionary = data_variant if data_variant is Dictionary else {}
if completion.is_valid(): completion.call(true, data)
)
func discover_destination(destination_id: String) -> void:
var normalized := destination_id.strip_edges()
if normalized.is_empty() or _discoveredDestinations.has(normalized) or _discoveryRequests.has(normalized):
return
_discoveryRequests[normalized] = true
_request_post("/world/travel-destinations/%s/discover" % normalized.uri_encode(), {}, func(success: bool, _response: Dictionary, _error: Dictionary) -> void:
_discoveryRequests.erase(normalized)
if success:
_discoveredDestinations[normalized] = true
)
func discover_nearby_destinations(map_id: String, position: Vector2) -> void:
for point in DISCOVERY_POINTS:
if str(point.get("mapId", "")) != map_id:
continue
var target := point.get("position", Vector2.ZERO) as Vector2
if position.distance_to(target) <= DISCOVERY_DISTANCE:
discover_destination(str(point.get("id", "")))
func toggle_notifications() -> void:
_notification_panel.visible = not _notification_panel.visible
if _notification_panel.visible:
_refresh_notifications()
func is_escape_dismissible() -> bool:
return (is_instance_valid(_profile_panel) and _profile_panel.visible) or (is_instance_valid(_notification_panel) and _notification_panel.visible)
func get_escape_priority() -> int:
return 800
func request_escape_close() -> void:
if is_instance_valid(_profile_panel) and _profile_panel.visible:
_close_profile()
return
if is_instance_valid(_notification_panel):
_notification_panel.visible = false
func _on_social_realtime(_payload: Dictionary) -> void:
if _notification_panel.visible:
_refresh_notifications()
func _refresh_notifications() -> void:
_request_get("/social/notifications?limit=30", func(success: bool, response: Dictionary, error: Dictionary) -> void:
if not success:
_show_notification_rows(["通知暂时无法读取:%s" % str(error.get("message", "请求失败"))])
return
var data_variant: Variant = response.get("data", {})
var data: Dictionary = data_variant if data_variant is Dictionary else {}
var rows: Array[String] = []
var notifications_variant: Variant = data.get("notifications", [])
if notifications_variant is Array:
for item in notifications_variant as Array:
if item is Dictionary:
var notification: Dictionary = item
rows.append("%s\n%s" % [str(notification.get("title", "通知")), str(notification.get("content", ""))])
if rows.is_empty(): rows.append("暂无通知")
_show_notification_rows(rows)
)
func _render_profile() -> void:
_profile_panel.visible = true
for child in _profile_content.get_children():
child.queue_free()
var nickname := str(_current_profile.get("nickname", "玩家"))
var username := str(_current_profile.get("username", ""))
var online := bool(_current_profile.get("online", false))
var area := str(_current_profile.get("currentArea", ""))
var identity := HBoxContainer.new()
identity.add_theme_constant_override("separation", 12)
identity.add_child(_avatar_badge(nickname))
var identity_text := VBoxContainer.new()
identity_text.size_flags_horizontal = Control.SIZE_EXPAND_FILL
identity_text.add_child(_label(nickname, 26, ACCENT))
identity_text.add_child(_label("@%s · %s" % [username, "在线" if online else "离线"], 15, Color(0.72, 0.82, 0.87, 1)))
identity.add_child(identity_text)
_profile_content.add_child(identity)
_profile_content.add_child(_label("当前区域:%s" % _area_label(area), 16, Color(0.88, 0.94, 0.96, 1)))
var skin_id := str(_current_profile.get("skinId", "")).strip_edges()
_profile_content.add_child(_label("角色:%s" % (skin_id if not skin_id.is_empty() else "默认角色"), 14, Color(0.50, 0.76, 0.80, 1)))
var bio := str(_current_profile.get("bio", "")).strip_edges()
_profile_content.add_child(_label(bio if not bio.is_empty() else "这个玩家还没有留下简介。", 16, Color(0.84, 0.90, 0.92, 1)))
var interests_variant: Variant = _current_profile.get("interests", [])
var interests: Array[String] = []
if interests_variant is Array:
for interest in interests_variant as Array: interests.append(str(interest))
_profile_content.add_child(_label("兴趣:%s" % (" · ".join(interests) if not interests.is_empty() else "未设置"), 14, Color(0.50, 0.76, 0.80, 1)))
var user_id := str(_current_profile.get("id", ""))
var self_profile := user_id == _current_user_id()
if self_profile:
_profile_content.add_child(_label("编辑社区资料", 17, ACCENT))
var nickname_input := LineEdit.new()
nickname_input.placeholder_text = "昵称(首次可立即修改,此后每 7 天一次)"
nickname_input.text = nickname
_profile_content.add_child(nickname_input)
var bio_input := TextEdit.new()
bio_input.placeholder_text = "简介(最多 160 字)"
bio_input.text = bio
bio_input.custom_minimum_size = Vector2(0, 82)
_profile_content.add_child(bio_input)
var current_interests: Array[String] = []
for interest in interests: current_interests.append(interest)
var interest_row := HBoxContainer.new()
interest_row.add_theme_constant_override("separation", 6)
var interest_selects: Array[OptionButton] = []
for slot in 3:
var selector := OptionButton.new()
selector.custom_minimum_size = Vector2(145, 34)
selector.add_item("兴趣标签")
for tag in _interest_catalog():
selector.add_item(str(tag.get("label", "")))
selector.set_item_metadata(selector.item_count - 1, str(tag.get("id", "")))
if slot < current_interests.size():
for item_index in range(1, selector.item_count):
if str(selector.get_item_metadata(item_index)) == current_interests[slot]:
selector.select(item_index)
break
interest_selects.append(selector)
interest_row.add_child(selector)
_profile_content.add_child(interest_row)
_profile_content.add_child(_button("保存资料", func() -> void: _save_own_profile(nickname_input.text, bio_input.text, interest_selects)))
else:
var actions := HBoxContainer.new()
actions.add_theme_constant_override("separation", 8)
actions.add_child(_button("私聊", func() -> void: open_private_chat(user_id, nickname)))
actions.add_child(_button("加好友", func() -> void: request_friend(user_id, nickname)))
if bool(_current_profile.get("room_visitable", false)):
actions.add_child(_button("访问房间", func() -> void: visit_room(user_id, nickname)))
_profile_content.add_child(actions)
var safety := HBoxContainer.new()
safety.add_theme_constant_override("separation", 8)
safety.add_child(_button("拉黑", func() -> void: _block_user(user_id)))
var report_form := _report_form(user_id)
var report_button := _button("举报", func() -> void: report_form.visible = not report_form.visible)
safety.add_child(report_button)
_profile_content.add_child(safety)
_profile_content.add_child(report_form)
_profile_content.add_child(_button("关闭", _close_profile))
func _block_user(user_id: String) -> void:
_request_post("/social/blocks", {"userId": user_id}, func(success: bool, _response: Dictionary, error: Dictionary) -> void:
_show_status("已拉黑该玩家" if success else str(error.get("message", "拉黑失败")))
if success: _close_profile()
)
func _save_own_profile(nickname: String, bio: String, interest_selects: Array[OptionButton]) -> void:
var interests: Array[String] = []
for selector in interest_selects:
var index := selector.selected
if index > 0:
var value := str(selector.get_item_metadata(index)).strip_edges()
if not value.is_empty() and not interests.has(value): interests.append(value)
_request_patch("/social/profile", {"nickname": nickname.strip_edges(), "bio": bio.strip_edges(), "interests": interests}, func(success: bool, response: Dictionary, error: Dictionary) -> void:
if not success:
_show_status(str(error.get("message", "资料保存失败")))
return
var data_variant: Variant = response.get("data", {})
if data_variant is Dictionary:
_current_profile = data_variant as Dictionary
_render_profile()
var event_system := get_node_or_null("/root/EventSystem")
if event_system != null: event_system.call("emit_event", EventNames.SOCIAL_PROFILE_UPDATED, _current_profile)
)
func _report_form(user_id: String) -> VBoxContainer:
var form := VBoxContainer.new()
form.visible = false
form.add_theme_constant_override("separation", 6)
form.add_theme_stylebox_override("panel", _subpanel_style())
form.add_child(_label("举报原因", 15, ACCENT))
var reason := OptionButton.new()
for item in [
{"id": "harassment", "label": "骚扰或辱骂"},
{"id": "spam", "label": "垃圾信息"},
{"id": "inappropriate_content", "label": "不当内容"},
{"id": "impersonation", "label": "冒充他人"},
{"id": "other", "label": "其他"},
]:
reason.add_item(str(item.get("label", "其他")))
reason.set_item_metadata(reason.item_count - 1, str(item.get("id", "other")))
form.add_child(reason)
var note := TextEdit.new()
note.placeholder_text = "补充说明(可选,最多 500 字)"
note.custom_minimum_size = Vector2(0, 68)
form.add_child(note)
var block_also := CheckBox.new()
block_also.text = "同时拉黑此玩家"
block_also.button_pressed = false
form.add_child(block_also)
form.add_child(_button("提交举报", func() -> void:
_submit_report(user_id, str(reason.get_item_metadata(reason.selected)), note.text, block_also.button_pressed)
))
return form
func _submit_report(user_id: String, reason: String, note: String, block_also: bool) -> void:
_request_post("/social/reports", {
"userId": user_id,
"reason": reason,
"note": note.strip_edges(),
"blockAlso": block_also,
}, func(success: bool, _response: Dictionary, error: Dictionary) -> void:
_show_status("举报已提交" if success else str(error.get("message", "举报失败")))
if success and block_also:
_close_profile()
)
func _build_ui() -> void:
_canvas = CanvasLayer.new()
_canvas.layer = 88
add_child(_canvas)
_profile_panel = _panel(Vector2(540, 610))
_profile_panel.set_anchors_preset(Control.PRESET_CENTER)
_profile_panel.position = Vector2(-270, -305)
_canvas.add_child(_profile_panel)
var profile_margin := _margin(_profile_panel)
var profile_scroll := ScrollContainer.new()
profile_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
profile_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
profile_margin.add_child(profile_scroll)
_profile_content = VBoxContainer.new()
_profile_content.add_theme_constant_override("separation", 12)
_profile_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
profile_scroll.add_child(_profile_content)
_notification_panel = _panel(Vector2(390, 430))
_notification_panel.set_anchors_preset(Control.PRESET_TOP_RIGHT)
_notification_panel.position = Vector2(-414, 110)
_canvas.add_child(_notification_panel)
var notification_margin := _margin(_notification_panel)
_notification_content = VBoxContainer.new()
_notification_content.add_theme_constant_override("separation", 9)
notification_margin.add_child(_notification_content)
_notification_panel.visible = false
_profile_panel.visible = false
func _show_notification_rows(rows: Array[String]) -> void:
for child in _notification_content.get_children(): child.queue_free()
_notification_content.add_child(_label("通知中心", 22, ACCENT))
for row in rows:
_notification_content.add_child(_label(row, 15, Color(0.87, 0.93, 0.95, 1)))
_notification_content.add_child(_button("全部标为已读", func() -> void:
_request_patch("/social/notifications/read-all", {}, func(_success: bool, _response: Dictionary, _error: Dictionary) -> void: _refresh_notifications())
))
func _panel(panel_size: Vector2) -> PanelContainer:
var panel := PanelContainer.new()
panel.size = panel_size
panel.mouse_filter = Control.MOUSE_FILTER_STOP
var style := StyleBoxFlat.new()
style.bg_color = PANEL_COLOR
style.border_color = Color(0.20, 0.52, 0.61, 0.9)
style.set_border_width_all(1)
style.set_corner_radius_all(12)
style.shadow_color = Color(0, 0, 0, 0.45)
style.shadow_size = 16
panel.add_theme_stylebox_override("panel", style)
return panel
func _margin(parent: Control) -> MarginContainer:
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 20)
margin.add_theme_constant_override("margin_top", 18)
margin.add_theme_constant_override("margin_right", 20)
margin.add_theme_constant_override("margin_bottom", 18)
parent.add_child(margin)
return margin
func _label(text: String, font_size: int, color: Color) -> Label:
var label := Label.new()
label.text = text
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.add_theme_font_size_override("font_size", font_size)
label.add_theme_color_override("font_color", color)
return label
func _button(text: String, callback: Callable) -> Button:
var button := Button.new()
button.text = text
button.custom_minimum_size = Vector2(92, 34)
button.pressed.connect(callback)
return button
func _avatar_badge(nickname: String) -> Label:
var badge := Label.new()
badge.text = nickname.left(1).to_upper() if not nickname.is_empty() else ""
badge.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
badge.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
badge.custom_minimum_size = Vector2(52, 52)
badge.add_theme_font_size_override("font_size", 22)
badge.add_theme_color_override("font_color", Color(0.92, 0.99, 1.0, 1.0))
var style := StyleBoxFlat.new()
style.bg_color = Color(0.12, 0.40, 0.48, 1.0)
style.set_corner_radius_all(26)
style.border_color = ACCENT
style.set_border_width_all(1)
badge.add_theme_stylebox_override("normal", style)
return badge
func _subpanel_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.06, 0.13, 0.18, 0.88)
style.set_border_width_all(1)
style.border_color = Color(0.18, 0.45, 0.52, 0.75)
style.set_corner_radius_all(8)
style.content_margin_left = 10
style.content_margin_right = 10
style.content_margin_top = 8
style.content_margin_bottom = 8
return style
func _close_profile() -> void:
_profile_panel.visible = false
func _show_status(message: String) -> void:
push_warning("Social: %s" % message)
func _request_get(endpoint: String, callback: Callable) -> void:
var api := get_node_or_null("/root/ApiClient")
if api != null: api.call("get_json", endpoint, callback, true)
func _request_post(endpoint: String, payload: Dictionary, callback: Callable) -> void:
var api := get_node_or_null("/root/ApiClient")
if api != null: api.call("post_json", endpoint, payload, callback, true)
func _request_patch(endpoint: String, payload: Dictionary, callback: Callable) -> void:
var api := get_node_or_null("/root/ApiClient")
if api != null: api.call("patch_json", endpoint, payload, callback, true)
func _current_user_id() -> String:
var auth := get_node_or_null("/root/AuthManager")
if auth != null and auth.has_method("get_current_user"):
var user_variant: Variant = auth.call("get_current_user")
if user_variant is Dictionary: return str((user_variant as Dictionary).get("id", ""))
return ""
func _area_label(map_id: String) -> String:
return {"whale_port": "中心广场", "work_zone": "打工区", "whale_cafe": "鲸鱼咖啡馆", "personal_space": "个人空间"}.get(map_id, map_id)
func _scene_name_to_id(scene_name: String) -> String:
return {
"Square": "square",
"WorkZone": "work_zone",
"CafeInterior": "cafe_interior",
"PersonalSpace": "personal_space",
}.get(scene_name, "")
func _interest_catalog() -> Array[Dictionary]:
return [
{"id": "ai", "label": "AI/大模型"}, {"id": "programming", "label": "编程开发"},
{"id": "data_science", "label": "数据科学"}, {"id": "open_source", "label": "开源协作"},
{"id": "product", "label": "产品"}, {"id": "design", "label": "设计"},
{"id": "game_dev", "label": "游戏开发"}, {"id": "content_creation", "label": "内容创作"},
{"id": "community", "label": "社区活动"}, {"id": "learning_partner", "label": "学习搭子"},
{"id": "career", "label": "职业成长"}, {"id": "casual_chat", "label": "轻松闲聊"},
]

View File

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

View File

@@ -1,181 +0,0 @@
extends Node
# 玩家任务书状态与后端任务接口的唯一入口。
signal board_changed(board: Dictionary)
signal board_failed(message: String)
signal reward_claimed(task_id: String, wallet: Dictionary)
var _board: Dictionary = {}
var _loading_board: bool = false
var _account_generation: int = -1
var _recent_activity_at: Dictionary = {}
const ACTIVITY_DEBOUNCE_MSEC: int = 500
const MAX_ACTIVITY_RETRIES: int = 3
const ACTIVITY_RETRY_BASE_SECONDS: float = 0.5
func _ready() -> void:
var auth_manager := get_node_or_null("/root/AuthManager")
if auth_manager != null and auth_manager.has_signal("auth_state_changed"):
var callback := Callable(self, "_on_auth_state_changed")
if not auth_manager.is_connected("auth_state_changed", callback):
auth_manager.connect("auth_state_changed", callback)
call_deferred("_refresh_after_ready")
func get_board() -> Dictionary:
return _board.duplicate(true)
func is_loaded() -> bool:
return not _board.is_empty()
func refresh_board() -> void:
if not _is_authenticated() or _loading_board:
return
var api := _api_client()
if api == null:
board_failed.emit("任务服务不可用")
return
_loading_board = true
_account_generation = _current_account_generation()
api.call("get_json", "/tasks/board", Callable(self, "_on_board_response").bind(_account_generation), true)
func report_activity(activity: String, target_id: String = "") -> void:
if not _is_authenticated():
return
var normalized_target_id := target_id.strip_edges()
var activity_key := "%s:%s" % [activity, normalized_target_id]
var now_msec := Time.get_ticks_msec()
if now_msec - int(_recent_activity_at.get(activity_key, 0)) < ACTIVITY_DEBOUNCE_MSEC:
return
_recent_activity_at[activity_key] = now_msec
_send_activity(activity, normalized_target_id, 0, _current_account_generation())
func _send_activity(activity: String, target_id: String, retry_count: int, request_generation: int) -> void:
if request_generation != _current_account_generation() or not _is_authenticated():
return
var api := _api_client()
if api == null:
return
var payload := {
"activity": activity,
"nonce": _create_activity_nonce(),
"occurred_at": Time.get_datetime_string_from_system(true, false),
}
if not target_id.is_empty():
payload["target_id"] = target_id
api.call(
"post_json",
"/tasks/activities",
payload,
Callable(self, "_on_activity_response").bind(activity, target_id, retry_count, request_generation),
true
)
func claim_task(task_id: String) -> void:
if task_id.strip_edges().is_empty() or not _is_authenticated():
return
var api := _api_client()
if api == null:
board_failed.emit("任务服务不可用")
return
var request_generation := _current_account_generation()
api.call("post_json", "/tasks/%s/claim" % task_id.uri_encode(), {}, Callable(self, "_on_claim_response").bind(task_id, request_generation), true)
func _refresh_after_ready() -> void:
if _is_authenticated():
refresh_board()
func _on_auth_state_changed(is_authenticated: bool, _user: Dictionary) -> void:
if not is_authenticated:
_board.clear()
_recent_activity_at.clear()
_loading_board = false
_account_generation = -1
board_changed.emit({})
return
refresh_board()
func _on_board_response(success: bool, response: Dictionary, error_info: Dictionary, request_generation: int) -> void:
if request_generation != _current_account_generation():
return
_loading_board = false
if not success:
board_failed.emit(str(error_info.get("message", "任务书读取失败")))
return
_apply_board_response(response)
func _on_activity_response(
success: bool,
response: Dictionary,
error_info: Dictionary,
activity: String,
target_id: String,
retry_count: int,
request_generation: int
) -> void:
if request_generation != _current_account_generation():
return
if success:
_apply_board_response(response)
return
if retry_count >= MAX_ACTIVITY_RETRIES or not _is_retriable_session_conflict(error_info):
return
var delay := ACTIVITY_RETRY_BASE_SECONDS * pow(2.0, retry_count)
get_tree().create_timer(delay).timeout.connect(
Callable(self, "_send_activity").bind(activity, target_id, retry_count + 1, request_generation),
CONNECT_ONE_SHOT
)
func _is_retriable_session_conflict(error_info: Dictionary) -> bool:
if int(error_info.get("response_code", 0)) != 409:
return false
var message := str(error_info.get("message", ""))
return message.contains("会话") or message.contains("位置") or message.contains("交互范围")
func _on_claim_response(success: bool, response: Dictionary, error_info: Dictionary, task_id: String, request_generation: int) -> void:
if request_generation != _current_account_generation():
return
if not success:
board_failed.emit(str(error_info.get("message", "奖励领取失败")))
return
var data_variant: Variant = response.get("data", {})
if not (data_variant is Dictionary):
board_failed.emit("任务奖励响应格式错误")
return
var data: Dictionary = data_variant as Dictionary
var board_variant: Variant = data.get("board", {})
if board_variant is Dictionary:
_apply_board(board_variant as Dictionary)
var wallet_variant: Variant = data.get("wallet", {})
if wallet_variant is Dictionary:
var wallet: Dictionary = wallet_variant as Dictionary
var player_state := get_node_or_null("/root/PlayerStateManager")
if player_state != null and player_state.has_method("apply_wallet"):
player_state.call("apply_wallet", wallet)
reward_claimed.emit(task_id, wallet.duplicate(true))
func _apply_board_response(response: Dictionary) -> void:
var data_variant: Variant = response.get("data", {})
if data_variant is Dictionary:
_apply_board(data_variant as Dictionary)
else:
board_failed.emit("任务书响应格式错误")
func _apply_board(board: Dictionary) -> void:
_board = board.duplicate(true)
board_changed.emit(get_board())
func _api_client() -> Node:
return get_node_or_null("/root/ApiClient")
func _is_authenticated() -> bool:
var auth_manager := get_node_or_null("/root/AuthManager")
return auth_manager != null and auth_manager.has_method("is_authenticated") and bool(auth_manager.call("is_authenticated"))
func _current_account_generation() -> int:
var auth_manager := get_node_or_null("/root/AuthManager")
return int(auth_manager.call("get_account_generation")) if auth_manager != null and auth_manager.has_method("get_account_generation") else 0
func _create_activity_nonce() -> String:
var random_bytes := Crypto.new().generate_random_bytes(16)
return random_bytes.hex_encode()

View File

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

View File

@@ -1,135 +0,0 @@
extends Node
# 游戏内管理员测试实验室的唯一 API 入口。
# 客户端只把 role=9 用于显示入口;实际授权仍由后端 TestLabGuard 完成。
signal status_changed(status: Dictionary)
signal status_failed(message: String)
signal operation_completed(message: String)
var _status: Dictionary = {}
var _loading_status: bool = false
var _account_generation: int = -1
func _ready() -> void:
var auth_manager := get_node_or_null("/root/AuthManager")
if auth_manager != null and auth_manager.has_signal("auth_state_changed"):
var callback := Callable(self, "_on_auth_state_changed")
if not auth_manager.is_connected("auth_state_changed", callback):
auth_manager.connect("auth_state_changed", callback)
call_deferred("_refresh_after_ready")
func get_status() -> Dictionary:
return _status.duplicate(true)
func is_loaded() -> bool:
return not _status.is_empty()
func is_admin() -> bool:
var auth_manager := get_node_or_null("/root/AuthManager")
if auth_manager == null or not auth_manager.has_method("is_authenticated") or not bool(auth_manager.call("is_authenticated")):
return false
if not auth_manager.has_method("get_current_user"):
return false
var user_variant: Variant = auth_manager.call("get_current_user")
if not (user_variant is Dictionary):
return false
var user: Dictionary = user_variant as Dictionary
return int(user.get("role", 0)) == 9
func refresh_status() -> void:
if not is_admin() or _loading_status:
return
var api := _api_client()
if api == null:
status_failed.emit("测试实验室服务不可用")
return
_loading_status = true
_account_generation = _current_account_generation()
api.call("get_json", "/admin/test-lab/status", Callable(self, "_on_status_response").bind(_account_generation), true)
func create_actor(nickname: String, map_id: String, x: int, y: int, skin_id: String) -> void:
_mutate("post_json", "/admin/test-lab/actors", {
"nickname": nickname.strip_edges(), "mapId": map_id, "x": x, "y": y, "skinId": skin_id
}, "测试假人已上线")
func update_actor(user_id: String, online: bool, map_id: String, x: int, y: int, skin_id: String) -> void:
_mutate("patch_json", "/admin/test-lab/actors/%s" % user_id.uri_encode(), {
"online": online, "mapId": map_id, "x": x, "y": y, "skinId": skin_id
}, "假人状态已同步")
func set_room_policy(user_id: String, policy: String) -> void:
_mutate("patch_json", "/admin/test-lab/actors/%s/room-policy" % user_id.uri_encode(), {
"roomVisitPolicy": policy
}, "房间访问策略已更新")
func send_message(user_id: String, scope: String, content: String, target_user_id: String = "") -> void:
var payload := {"scope": scope, "content": content.strip_edges()}
if scope == "private":
payload["targetUserId"] = target_user_id
_mutate("post_json", "/admin/test-lab/actors/%s/messages" % user_id.uri_encode(), payload, "测试消息已发送")
func social_action(user_id: String, action: String, target_user_id: String) -> void:
_mutate("post_json", "/admin/test-lab/actors/%s/social" % user_id.uri_encode(), {
"action": action, "targetUserId": target_user_id
}, "社交操作已执行")
func delete_actor(user_id: String) -> void:
_mutate("delete_json", "/admin/test-lab/actors/%s" % user_id.uri_encode(), {}, "测试假人已删除")
func clear_all() -> void:
_mutate("delete_json", "/admin/test-lab/actors", {}, "测试实验室已清空")
func _mutate(method_name: String, endpoint: String, payload: Dictionary, success_message: String) -> void:
if not is_admin():
status_failed.emit("仅管理员可使用测试实验室")
return
var api := _api_client()
if api == null:
status_failed.emit("测试实验室服务不可用")
return
var request_generation := _current_account_generation()
api.call(method_name, endpoint, payload, Callable(self, "_on_mutation_response").bind(success_message, request_generation), true)
func _refresh_after_ready() -> void:
if is_admin():
refresh_status()
func _on_auth_state_changed(is_authenticated: bool, _user: Dictionary) -> void:
if not is_authenticated or not is_admin():
_status.clear()
_loading_status = false
_account_generation = -1
status_changed.emit({})
return
refresh_status()
func _on_status_response(success: bool, response: Dictionary, error_info: Dictionary, request_generation: int) -> void:
if request_generation != _current_account_generation():
return
_loading_status = false
if not success:
status_failed.emit(str(error_info.get("message", "测试实验室读取失败")))
return
var data_variant: Variant = response.get("data", {})
if not (data_variant is Dictionary):
status_failed.emit("测试实验室响应格式错误")
return
_status = (data_variant as Dictionary).duplicate(true)
status_changed.emit(get_status())
func _on_mutation_response(success: bool, _response: Dictionary, error_info: Dictionary, success_message: String, request_generation: int) -> void:
if request_generation != _current_account_generation():
return
if not success:
status_failed.emit(str(error_info.get("message", "测试实验室操作失败")))
return
operation_completed.emit(success_message)
refresh_status()
func _api_client() -> Node:
return get_node_or_null("/root/ApiClient")
func _current_account_generation() -> int:
var auth_manager := get_node_or_null("/root/AuthManager")
return int(auth_manager.call("get_account_generation")) if auth_manager != null and auth_manager.has_method("get_account_generation") else 0

View File

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

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

@@ -1,58 +0,0 @@
extends Node
# 统一处理 UI 的 Esc 退出。可关闭界面加入 whaletown_escape_dismissible 分组,
# 并实现 is_escape_dismissible / get_escape_priority / request_escape_close。
const ESCAPE_DISMISSIBLE_GROUP: StringName = &"whaletown_escape_dismissible"
func _ready() -> void:
# 公告、排行榜等界面会暂停场景树Esc 仍必须有效。
process_mode = Node.PROCESS_MODE_ALWAYS
func _input(event: InputEvent) -> void:
if not (event is InputEventKey):
return
var key_event: InputEventKey = event as InputEventKey
if not key_event.pressed or key_event.echo or key_event.keycode != KEY_ESCAPE:
return
if _hide_open_popup_menu() or _close_topmost_dismissible() or _release_control_focus():
get_viewport().set_input_as_handled()
func _hide_open_popup_menu() -> bool:
var popup_nodes: Array[Node] = get_tree().root.find_children("*", "PopupMenu", true, false)
for popup_node: Node in popup_nodes:
var popup: PopupMenu = popup_node as PopupMenu
if popup != null and popup.visible:
popup.hide()
return true
return false
func _close_topmost_dismissible() -> bool:
var target: Node = null
var target_priority: int = -2147483648
var dismissibles: Array[Node] = get_tree().get_nodes_in_group(ESCAPE_DISMISSIBLE_GROUP)
for dismissible: Node in dismissibles:
if not is_instance_valid(dismissible):
continue
if not dismissible.has_method("is_escape_dismissible") or not dismissible.has_method("request_escape_close"):
continue
var is_dismissible_variant: Variant = dismissible.call("is_escape_dismissible")
if not bool(is_dismissible_variant):
continue
var priority: int = 0
if dismissible.has_method("get_escape_priority"):
var priority_variant: Variant = dismissible.call("get_escape_priority")
priority = int(priority_variant)
if target == null or priority > target_priority:
target = dismissible
target_priority = priority
if target == null:
return false
target.call("request_escape_close")
return true
func _release_control_focus() -> bool:
var focus_owner: Control = get_viewport().gui_get_focus_owner()
if focus_owner == null:
return false
focus_owner.release_focus()
return true

View File

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

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

@@ -1,92 +0,0 @@
extends RefCounted
var _parent: Node2D
var _collision_layer: int
var _collision_mask: int
var _bodies: Dictionary = {}
func _init(parent: Node2D, collision_layer: int, collision_mask: int) -> void:
_parent = parent
_collision_layer = collision_layer
_collision_mask = collision_mask
func sync(decor_id: String, item: Dictionary) -> void:
var collision_size := _to_vector2(item.get("collision_size", Vector2.ZERO))
if collision_size == Vector2.ZERO:
remove(decor_id)
return
var collision_offset := _to_vector2(item.get("collision_offset", Vector2.ZERO))
var body := _bodies.get(decor_id, null) as StaticBody2D
var shape_node: CollisionShape2D
if body == null or not is_instance_valid(body):
body = StaticBody2D.new()
body.name = "DecorCollision_%s" % decor_id
body.collision_layer = _collision_layer
body.collision_mask = _collision_mask
shape_node = CollisionShape2D.new()
shape_node.name = "CollisionShape2D"
shape_node.shape = RectangleShape2D.new()
body.add_child(shape_node)
_parent.add_child(body)
_bodies[decor_id] = body
else:
shape_node = body.get_node_or_null("CollisionShape2D") as CollisionShape2D
if shape_node == null:
shape_node = CollisionShape2D.new()
shape_node.name = "CollisionShape2D"
shape_node.shape = RectangleShape2D.new()
body.add_child(shape_node)
var scale := _to_float(item.get("scale", item.get("default_scale", 1.0)), 1.0)
var rotation_degrees := _to_float(
item.get("rotation_degrees", item.get("default_rotation_degrees", 0.0)),
0.0
)
body.rotation_degrees = rotation_degrees
body.global_position = Vector2(
_to_float(item.get("position_x", 0.0), 0.0),
_to_float(item.get("position_y", 0.0), 0.0)
) + collision_offset.rotated(deg_to_rad(rotation_degrees)) * scale
shape_node.position = Vector2.ZERO
var rectangle := shape_node.shape as RectangleShape2D
if rectangle == null:
rectangle = RectangleShape2D.new()
shape_node.shape = rectangle
rectangle.size = Vector2(maxf(8.0, collision_size.x * scale), maxf(8.0, collision_size.y * scale))
func set_disabled(decor_id: String, disabled: bool) -> void:
var body := _bodies.get(decor_id, null) as StaticBody2D
if body == null or not is_instance_valid(body):
return
var shape_node := body.get_node_or_null("CollisionShape2D") as CollisionShape2D
if shape_node != null:
shape_node.disabled = disabled
func remove(decor_id: String) -> void:
var body := _bodies.get(decor_id, null) as Node
if body != null and is_instance_valid(body):
body.queue_free()
_bodies.erase(decor_id)
func _to_vector2(value: Variant) -> Vector2:
if value is Vector2:
return value
if value is Dictionary:
var dictionary: Dictionary = value
return Vector2(
_to_float(dictionary.get("x", 0.0), 0.0),
_to_float(dictionary.get("y", 0.0), 0.0)
)
return Vector2.ZERO
func _to_float(value: Variant, fallback: float) -> float:
match typeof(value):
TYPE_FLOAT, TYPE_INT:
return float(value)
TYPE_STRING:
var text := str(value).strip_edges()
return fallback if text.is_empty() else text.to_float()
TYPE_BOOL:
return 1.0 if bool(value) else 0.0
_:
return fallback

View File

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

View File

@@ -1,60 +0,0 @@
extends RefCounted
var _undo_entries: Array[Dictionary] = []
var _redo_entries: Array[Dictionary] = []
func clear() -> void:
_undo_entries.clear()
_redo_entries.clear()
func record_item(before: Dictionary, after: Dictionary) -> bool:
if before == after:
return false
var decor_id := str(after.get("decor_id", before.get("decor_id", ""))).strip_edges()
if decor_id.is_empty():
return false
_undo_entries.append({
"decor_id": decor_id,
"before": before.duplicate(true),
"after": after.duplicate(true),
})
_redo_entries.clear()
return true
func record_bulk(before_items: Dictionary, after_items: Dictionary) -> bool:
if before_items == after_items:
return false
_undo_entries.append({
"before_items": _duplicate_item_map(before_items),
"after_items": _duplicate_item_map(after_items),
})
_redo_entries.clear()
return true
func can_undo() -> bool:
return not _undo_entries.is_empty()
func can_redo() -> bool:
return not _redo_entries.is_empty()
func take_undo() -> Dictionary:
if _undo_entries.is_empty():
return {}
var entry: Dictionary = _undo_entries.pop_back()
_redo_entries.append(entry)
return entry
func take_redo() -> Dictionary:
if _redo_entries.is_empty():
return {}
var entry: Dictionary = _redo_entries.pop_back()
_undo_entries.append(entry)
return entry
func _duplicate_item_map(source: Dictionary) -> Dictionary:
var duplicate: Dictionary = {}
for decor_id_variant in source.keys():
var item_variant: Variant = source.get(decor_id_variant, {})
if item_variant is Dictionary:
duplicate[str(decor_id_variant)] = (item_variant as Dictionary).duplicate(true)
return duplicate

View File

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

View File

@@ -1,130 +0,0 @@
extends RefCounted
const STORAGE_VERSION: int = 2
const BLOCK_SIZE: int = 16
const KEY_CONTEXT: String = "WhaleTown-V2/session-store/v1"
func save_refresh_token(path: String, refresh_token: String) -> bool:
if refresh_token.is_empty():
clear(path)
return true
var keys := _derive_keys()
if keys.is_empty():
return false
var iv := Crypto.new().generate_random_bytes(BLOCK_SIZE)
var encryption_key: PackedByteArray = keys.get("encryption", PackedByteArray())
var authentication_key: PackedByteArray = keys.get("authentication", PackedByteArray())
var encrypted := _encrypt(refresh_token.to_utf8_buffer(), encryption_key, iv)
if encrypted.is_empty():
return false
var authenticated_data := iv.duplicate()
authenticated_data.append_array(encrypted)
var mac := Crypto.new().hmac_digest(HashingContext.HASH_SHA256, authentication_key, authenticated_data)
var payload := {
"version": STORAGE_VERSION,
"iv": Marshalls.raw_to_base64(iv),
"ciphertext": Marshalls.raw_to_base64(encrypted),
"mac": Marshalls.raw_to_base64(mac),
"saved_at": Time.get_unix_time_from_system(),
}
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return false
file.store_string(JSON.stringify(payload))
file.close()
return true
func load_refresh_token(path: String) -> String:
if not FileAccess.file_exists(path):
return ""
var keys := _derive_keys()
if keys.is_empty():
clear(path)
return ""
var json := JSON.new()
if json.parse(FileAccess.get_file_as_string(path)) != OK or not (json.data is Dictionary):
clear(path)
return ""
var payload: Dictionary = json.data
if int(payload.get("version", 0)) != STORAGE_VERSION:
clear(path)
return ""
var iv := Marshalls.base64_to_raw(str(payload.get("iv", "")))
var encrypted := Marshalls.base64_to_raw(str(payload.get("ciphertext", "")))
var expected_mac := Marshalls.base64_to_raw(str(payload.get("mac", "")))
if iv.size() != BLOCK_SIZE or encrypted.is_empty() or expected_mac.is_empty():
clear(path)
return ""
var authenticated_data := iv.duplicate()
authenticated_data.append_array(encrypted)
var encryption_key: PackedByteArray = keys.get("encryption", PackedByteArray())
var authentication_key: PackedByteArray = keys.get("authentication", PackedByteArray())
var actual_mac := Crypto.new().hmac_digest(HashingContext.HASH_SHA256, authentication_key, authenticated_data)
if not _constant_time_equals(actual_mac, expected_mac):
clear(path)
return ""
var decrypted := _decrypt(encrypted, encryption_key, iv)
return decrypted.get_string_from_utf8().strip_edges()
func clear(path: String) -> void:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
func _derive_keys() -> Dictionary:
if OS.get_name() == "Web":
return {}
var device_id := OS.get_unique_id().strip_edges()
if device_id.is_empty():
return {}
var root_key := _sha256((KEY_CONTEXT + ":" + device_id).to_utf8_buffer())
if root_key.is_empty():
return {}
return {
"encryption": _sha256(root_key + ":encryption".to_utf8_buffer()),
"authentication": _sha256(root_key + ":authentication".to_utf8_buffer()),
}
func _sha256(bytes: PackedByteArray) -> PackedByteArray:
var hash_context := HashingContext.new()
if hash_context.start(HashingContext.HASH_SHA256) != OK:
return PackedByteArray()
if hash_context.update(bytes) != OK:
return PackedByteArray()
return hash_context.finish()
func _encrypt(plain_text: PackedByteArray, key: PackedByteArray, iv: PackedByteArray) -> PackedByteArray:
var padded := plain_text.duplicate()
var padding_size := BLOCK_SIZE - (padded.size() % BLOCK_SIZE)
for _index in range(padding_size):
padded.append(padding_size)
var aes := AESContext.new()
if aes.start(AESContext.MODE_CBC_ENCRYPT, key, iv) != OK:
return PackedByteArray()
var encrypted := aes.update(padded)
aes.finish()
return encrypted
func _decrypt(encrypted: PackedByteArray, key: PackedByteArray, iv: PackedByteArray) -> PackedByteArray:
var aes := AESContext.new()
if aes.start(AESContext.MODE_CBC_DECRYPT, key, iv) != OK:
return PackedByteArray()
var padded := aes.update(encrypted)
aes.finish()
if padded.is_empty():
return PackedByteArray()
var padding_size := int(padded[padded.size() - 1])
if padding_size < 1 or padding_size > BLOCK_SIZE or padding_size > padded.size():
return PackedByteArray()
for index in range(padded.size() - padding_size, padded.size()):
if int(padded[index]) != padding_size:
return PackedByteArray()
padded.resize(padded.size() - padding_size)
return padded
func _constant_time_equals(left: PackedByteArray, right: PackedByteArray) -> bool:
if left.size() != right.size():
return false
var difference := 0
for index in range(left.size()):
difference |= int(left[index]) ^ int(right[index])
return difference == 0

View File

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

View File

@@ -1,20 +0,0 @@
extends Node2D
# 地图内可交互目标的轻量视觉标记,由 InteractionManager 提供世界坐标。
const RING_COLOR: Color = Color(1.0, 1.0, 1.0, 0.94)
const CORE_COLOR: Color = Color(1.0, 1.0, 1.0, 0.58)
const RING_RADIUS: float = 9.0
const RING_WIDTH: float = 2.0
var _points: Array[Vector2] = []
func set_points(points: Array[Vector2]) -> void:
if _points == points:
return
_points = points.duplicate()
queue_redraw()
func _draw() -> void:
for point: Vector2 in _points:
draw_arc(point, RING_RADIUS, 0.0, TAU, 24, RING_COLOR, RING_WIDTH, true)
draw_circle(point, 2.0, CORE_COLOR)

View File

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

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

File diff suppressed because one or more lines are too long

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

@@ -7,9 +7,9 @@ advanced_options=false
dedicated_server=false
custom_features=""
export_filter="scenes"
export_files=PackedStringArray("res://scenes/Maps/cafe_interior.tscn", "res://scenes/Maps/personal_space.tscn", "res://scenes/Maps/square.tscn", "res://scenes/Maps/work_zone.tscn", "res://scenes/characters/cafe_whale_barista_npc.tscn", "res://scenes/characters/crayfish_npc.tscn", "res://scenes/characters/npc.tscn", "res://scenes/characters/player.tscn", "res://scenes/characters/remote_player.tscn", "res://scenes/prefabs/ui/ChatMessage.tscn", "res://scenes/ui/AuthScene.tscn", "res://scenes/ui/CafeCompanionPanel.tscn", "res://scenes/ui/CafeCompanionRecruitmentPanel.tscn", "res://scenes/ui/ChatBubble.tscn", "res://scenes/ui/ChatUI.tscn", "res://scenes/ui/CourseBoardPanel.tscn", "res://scenes/ui/FriendListPanel.tscn", "res://scenes/ui/MapPanel.tscn", "res://scenes/ui/PlayerHud.tscn", "res://scenes/ui/SettingsPanel.tscn", "res://scenes/ui/datawhale_honor_ranking_panel.tscn", "res://scenes/ui/mall/MallPanel.tscn", "res://scenes/ui/notice_dialog.tscn", "res://scenes/ui/welcome_dialog.tscn")
include_filter="Config/*.gd,Config/*.json,_Core/*.gd,_Core/chat/*.gd,_Core/interactions/*.gd,_Core/managers/*.gd,_Core/room_decor/*.gd,_Core/security/*.gd,_Core/systems/*.gd,_Core/ui/*.gd,_Core/utils/*.gd,scenes/Maps/*.gd,scenes/characters/*.gd,scenes/prefabs/items/*.gd,scenes/prefabs/ui/*.gd,scenes/ui/*.gd,scenes/ui/mall/*.gd,assets/audio/ui/*,assets/characters/skins/*,assets/maps/personal_space/v1/decor/*,assets/ui/auth/generated/*,assets/ui/auth/redesign/*,assets/ui/auth/registration_choice/redesign/*,assets/ui/auth/v1/*,assets/ui/mall/branding/*,assets/ui/mall/icons/*,assets/ui/mall/icons/processed/*,assets/ui/mall/items/*,assets/ui/mall/skin/*,assets/ui/mall/skin/clean/*,assets/ui/mall/skins/*,assets/ui/settings/*"
exclude_filter="build/*,tools/*,docs/*,scripts/*,assets/ui/auth/registration_choice/preview/*,assets/ui/auth/registration_choice/redesign_preview/*"
export_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()
encryption_include_filters=""
@@ -28,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
@@ -51,10 +51,9 @@ runnable=false
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="scenes"
export_files=PackedStringArray("res://scenes/Maps/cafe_interior.tscn", "res://scenes/Maps/personal_space.tscn", "res://scenes/Maps/square.tscn", "res://scenes/Maps/work_zone.tscn", "res://scenes/characters/cafe_whale_barista_npc.tscn", "res://scenes/characters/crayfish_npc.tscn", "res://scenes/characters/npc.tscn", "res://scenes/characters/player.tscn", "res://scenes/characters/remote_player.tscn", "res://scenes/prefabs/ui/ChatMessage.tscn", "res://scenes/ui/AuthScene.tscn", "res://scenes/ui/CafeCompanionPanel.tscn", "res://scenes/ui/CafeCompanionRecruitmentPanel.tscn", "res://scenes/ui/ChatBubble.tscn", "res://scenes/ui/ChatUI.tscn", "res://scenes/ui/CourseBoardPanel.tscn", "res://scenes/ui/FriendListPanel.tscn", "res://scenes/ui/MapPanel.tscn", "res://scenes/ui/PlayerHud.tscn", "res://scenes/ui/SettingsPanel.tscn", "res://scenes/ui/datawhale_honor_ranking_panel.tscn", "res://scenes/ui/mall/MallPanel.tscn", "res://scenes/ui/notice_dialog.tscn", "res://scenes/ui/welcome_dialog.tscn")
include_filter="Config/*.gd,Config/*.json,_Core/*.gd,_Core/chat/*.gd,_Core/interactions/*.gd,_Core/managers/*.gd,_Core/room_decor/*.gd,_Core/security/*.gd,_Core/systems/*.gd,_Core/ui/*.gd,_Core/utils/*.gd,scenes/Maps/*.gd,scenes/characters/*.gd,scenes/prefabs/items/*.gd,scenes/prefabs/ui/*.gd,scenes/ui/*.gd,scenes/ui/mall/*.gd,assets/audio/ui/*,assets/characters/skins/*,assets/maps/personal_space/v1/decor/*,assets/ui/auth/generated/*,assets/ui/auth/redesign/*,assets/ui/auth/registration_choice/redesign/*,assets/ui/auth/v1/*,assets/ui/mall/branding/*,assets/ui/mall/icons/*,assets/ui/mall/icons/processed/*,assets/ui/mall/items/*,assets/ui/mall/skin/*,assets/ui/mall/skin/clean/*,assets/ui/mall/skins/*,assets/ui/settings/*"
exclude_filter="build/*,tools/*,docs/*,scripts/*,assets/ui/auth/registration_choice/preview/*,assets/ui/auth/registration_choice/redesign_preview/*"
export_filter="all_resources"
include_filter=""
exclude_filter="build/**,output/**,docs/**,scripts/**,tools/**"
export_path="build/linux/WhaleTown-V2.x86_64"
patches=PackedStringArray()
encryption_include_filters=""
@@ -74,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,14 +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]
UiEscapeManager="*res://_Core/managers/UiEscapeManager.gd"
UIFontManager="*res://_Core/managers/UIFontManager.gd"
SceneManager="*res://_Core/managers/SceneManager.gd"
EventSystem="*res://_Core/systems/EventSystem.gd"
ApiClient="*res://_Core/managers/ApiClient.gd"
@@ -33,12 +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"
SocialManager="*res://_Core/managers/SocialManager.gd"
InteractionManager="*res://_Core/managers/InteractionManager.gd"
TaskManager="*res://_Core/managers/TaskManager.gd"
TaskBookPanel="*res://scenes/ui/TaskBookPanel.gd"
TestLabManager="*res://_Core/managers/TestLabManager.gd"
AdminTestLabPanel="*res://scenes/ui/AdminTestLabPanel.gd"
InputFocusManager="*res://_Core/managers/InputFocusManager.gd"
[display]
@@ -47,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={
@@ -78,6 +78,11 @@ interact={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
]
}
friend_request={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":102,"location":0,"echo":false,"script":null)
]
}
[rendering]

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()
@@ -45,22 +47,6 @@ func _ready() -> void:
_connect_exit_area()
_connect_recruitment_area()
_connect_cafe_companion_events()
_register_interactables()
_discover_destination()
_report_task_map_visit()
func _report_task_map_visit() -> void:
var taskManager := get_node_or_null("/root/TaskManager")
if taskManager != null and taskManager.has_method("report_activity"):
taskManager.call("report_activity", "map_visited", "whale_cafe")
func _discover_destination() -> void:
var destination_id := SceneManager.get_next_destination_id()
if destination_id.is_empty():
destination_id = "cafe_entrance"
var socialManager := get_node_or_null("/root/SocialManager")
if socialManager != null and socialManager.has_method("discover_destination"):
socialManager.call("discover_destination", destination_id)
func _exit_tree() -> void:
var eventSystem := get_node_or_null("/root/EventSystem")
@@ -72,8 +58,6 @@ func _align_service_occupants() -> void:
cafeWhaleBaristaNpc.global_position = serviceIdlePoint01.global_position
func _apply_spawn_point() -> void:
if player.has_scene_position_override:
return
var spawnName: String = SceneManager.get_next_spawn_name()
var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn"
var marker := $Markers.get_node_or_null(markerName) as Marker2D
@@ -93,7 +77,8 @@ func _configure_camera() -> void:
playerCamera.limit_smoothed = true
func _connect_exit_area() -> void:
exitToWorkZoneArea.collision_mask = 0
if not exitToWorkZoneArea.body_entered.is_connected(_on_exit_area_body_entered):
exitToWorkZoneArea.body_entered.connect(_on_exit_area_body_entered)
func _connect_recruitment_area() -> void:
cafeRecruitmentLogoArea.input_pickable = true
@@ -108,30 +93,8 @@ func _connect_cafe_companion_events() -> void:
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_AGENT_REGISTERED, _on_cafe_companion_agent_registered, self)
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_EMPLOYMENT_RESIGNED, _on_cafe_companion_employment_resigned, self)
func _register_interactables() -> void:
var exit_interactable: InteractableComponent = InteractableComponent.new()
exit_interactable.interaction_id = "cafe_exit"
exit_interactable.interaction_title = "离开咖啡馆"
exit_interactable.interaction_priority = 20
exit_interactable.interaction_distance = 160.0
exit_interactable.activation_method = &"_leave_to_work_zone"
exit_interactable.anchor_path = NodePath("InteractionAreas/ExitToWorkZoneArea")
add_child(exit_interactable)
var recruitment_interactable: InteractableComponent = InteractableComponent.new()
recruitment_interactable.interaction_id = "cafe_recruitment"
recruitment_interactable.interaction_title = "登记咖啡店陪伴机器人"
recruitment_interactable.interaction_priority = 30
recruitment_interactable.interaction_distance = 150.0
recruitment_interactable.activation_method = &"_try_emit_recruitment_selected"
recruitment_interactable.anchor_path = NodePath("InteractionAreas/CafeRecruitmentLogoArea")
add_child(recruitment_interactable)
func _on_exit_area_body_entered(body: Node2D) -> void:
return
func _leave_to_work_zone() -> void:
if _isChangingScene:
if _isChangingScene or body != player:
return
_isChangingScene = true
SceneManager.set_next_scene_position(WORK_ZONE_CAFE_RETURN_POSITION)
@@ -291,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)
@@ -56,10 +67,19 @@ func _ready() -> void:
call_deferred("_send_world_ready")
func _process(_delta: float) -> void:
return
if Input.is_action_just_pressed("interact"):
_try_start_private_chat()
if Input.is_action_just_pressed("friend_request"):
_try_request_friend()
func _unhandled_input(event: InputEvent) -> void:
return
if event.is_action_pressed("interact"):
if _try_start_private_chat():
get_viewport().set_input_as_handled()
return
if event.is_action_pressed("friend_request") and _try_request_friend():
get_viewport().set_input_as_handled()
func _exit_tree() -> void:
var eventSystem := _get_event_system()
@@ -69,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)
@@ -108,7 +131,7 @@ func _is_text_input_focused() -> bool:
return false
func _on_interact_pressed(_data: Dictionary = {}) -> void:
return
_try_start_private_chat()
func _try_start_private_chat() -> bool:
if _is_text_input_focused():
@@ -166,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:
@@ -174,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()
@@ -191,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):
@@ -201,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):
@@ -221,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)
@@ -237,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
@@ -251,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):
@@ -348,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()
@@ -363,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():
@@ -427,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()

File diff suppressed because it is too large Load Diff

View File

@@ -10,33 +10,19 @@ extends Area2D
@export var targetSceneName: String = ""
@export var targetSpawnName: String = ""
@export var targetPosition: Vector2 = Vector2.ZERO
@export var interactionTitle: String = "进入"
@export var interactionDistance: float = 160.0
func _ready() -> void:
var label: String = interactionTitle.strip_edges()
if label.is_empty():
label = "进入 %s" % targetSceneName
var interactable: InteractableComponent = InteractableComponent.new()
interactable.interaction_id = "portal:%s" % str(get_path())
interactable.interaction_title = label
interactable.interaction_priority = 20
interactable.interaction_distance = interactionDistance
interactable.activation_method = &"_change_scene"
add_child(interactable)
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node2D) -> void:
return
if not (body is PlayerController):
return
_change_scene()
func _change_scene() -> void:
if targetSceneName.is_empty():
push_warning("ScenePortal: targetSceneName is empty.")
return
if targetSceneName == "personal_space":
SceneManager.clear_room_visit_context()
elif targetSceneName == "square" and SceneManager.has_room_visit_context():
SceneManager.return_from_room_visit()
return
if not targetSpawnName.is_empty():
SceneManager.set_next_spawn_name(targetSpawnName)

View File

@@ -12,8 +12,6 @@ const CAMERA_LIMIT_LEFT: int = -1280
const CAMERA_LIMIT_TOP: int = -960
const CAMERA_LIMIT_RIGHT: int = 1280
const CAMERA_LIMIT_BOTTOM: int = 960
const WELCOME_DIALOG_SCENE: PackedScene = preload("res://scenes/ui/welcome_dialog.tscn")
const WELCOME_DIALOG_NAME: String = "WelcomeDialog"
@onready var player: PlayerController = $YSortWorld/Characters/Players/Player
@onready var playerCamera: Camera2D = $YSortWorld/Characters/Players/Player/Camera2D
@@ -22,41 +20,11 @@ const WELCOME_DIALOG_NAME: String = "WelcomeDialog"
func _ready() -> void:
_apply_spawn_point()
_configure_camera()
_discover_destination()
call_deferred("_show_registration_welcome")
_report_task_map_visit()
func _report_task_map_visit() -> void:
var taskManager := get_node_or_null("/root/TaskManager")
if taskManager != null and taskManager.has_method("report_activity"):
taskManager.call("report_activity", "map_visited", "square")
func _show_registration_welcome() -> void:
var auth_manager: Node = get_node_or_null("/root/AuthManager")
if auth_manager == null or not auth_manager.has_method("consume_registration_welcome"):
return
if not bool(auth_manager.call("consume_registration_welcome")):
return
var root: Window = get_tree().root
if root.has_node(WELCOME_DIALOG_NAME):
return
var dialog: CanvasLayer = WELCOME_DIALOG_SCENE.instantiate() as CanvasLayer
if dialog == null:
return
dialog.name = WELCOME_DIALOG_NAME
root.add_child(dialog)
func _discover_destination() -> void:
var destination_id := SceneManager.get_next_destination_id()
if destination_id.is_empty():
destination_id = "square_center"
var socialManager := get_node_or_null("/root/SocialManager")
if socialManager != null and socialManager.has_method("discover_destination"):
socialManager.call("discover_destination", destination_id)
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:
if player.has_scene_position_override:
return
var spawnName: String = SceneManager.get_next_spawn_name()
var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn"
var marker := $Markers.get_node_or_null(markerName) as Marker2D

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