Compare commits
12 Commits
refactor-e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fc6c3c1bd3 | |||
| 175621f66c | |||
| 366f967622 | |||
| 6fb135ba71 | |||
| 38df2541ae | |||
| 32bcaef4fa | |||
| 28c5e5ccfb | |||
| a274f6475f | |||
| 52c5a76a01 | |||
| 7a07f52e2c | |||
| bcdaa737eb | |||
| 593e1e4ea1 |
13
.gitattributes
vendored
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 校验值。
|
||||
11
README.md
@@ -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`
|
||||
|
||||
@@ -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"
|
||||
|
||||
# ============================================================================
|
||||
# 商城事件
|
||||
|
||||
@@ -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)
|
||||
@@ -1 +0,0 @@
|
||||
uid://dao88q6dkwueb
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
uid://cn3fccr0qc41h
|
||||
@@ -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()
|
||||
@@ -1 +0,0 @@
|
||||
uid://dbrui8h3sbjrn
|
||||
@@ -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:
|
||||
|
||||
@@ -21,6 +21,7 @@ const REMOTE_SKIN_MAX_RETRIES: int = 3
|
||||
const REMOTE_SKIN_CACHE_DIR: String = "user://skin_cache"
|
||||
const AVATAR_CORNER_RADIUS_RATIO: float = 0.22
|
||||
const DEFAULT_CHARACTER_TEXTURE_PATH: String = "res://assets/characters/player_pixel_spritesheet.png"
|
||||
const AVATAR_MASK_SHADER = preload("res://assets/shaders/avatar_round_mask.gdshader")
|
||||
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
|
||||
|
||||
const SKINS: Array[Dictionary] = [
|
||||
@@ -99,9 +100,12 @@ var _remoteSkinRetryCounts: Dictionary = {}
|
||||
var _accountAvatarUrl: String = ""
|
||||
var _accountAvatarTexture: Texture2D
|
||||
var _profileSyncSuppressed: bool = false
|
||||
var _confirmedSkinId: String = FALLBACK_SKIN_ID
|
||||
var _pendingSkinId: String = ""
|
||||
|
||||
func _ready() -> void:
|
||||
_load()
|
||||
_confirmedSkinId = selectedSkinId
|
||||
_connect_auth_events()
|
||||
_emit_profile_changed()
|
||||
|
||||
@@ -226,12 +230,17 @@ func set_selected_skin(skinId: String, allowLocked: bool = false) -> bool:
|
||||
if normalizedId == selectedSkinId:
|
||||
return true
|
||||
|
||||
var shouldSync := not _profileSyncSuppressed and _is_account_authenticated()
|
||||
if shouldSync:
|
||||
_pendingSkinId = normalizedId
|
||||
selectedSkinId = normalizedId
|
||||
_save()
|
||||
_emit_skin_changed()
|
||||
_emit_profile_changed()
|
||||
if not _profileSyncSuppressed:
|
||||
if shouldSync:
|
||||
_sync_account_skin_profile()
|
||||
else:
|
||||
_confirmedSkinId = normalizedId
|
||||
return true
|
||||
|
||||
func apply_account_profile(profile: Dictionary) -> void:
|
||||
@@ -240,23 +249,28 @@ func apply_account_profile(profile: Dictionary) -> void:
|
||||
_clear_session_custom_skin()
|
||||
var skinId := str(profile.get("skin_id", "")).strip_edges()
|
||||
_accountAvatarUrl = _absolute_backend_url(str(profile.get("avatar_url", "")).strip_edges())
|
||||
_accountAvatarTexture = _texture_from_base64(str(profile.get("avatar_base64", "")).strip_edges())
|
||||
_accountAvatarTexture = _texture_from_base64(str(profile.get("avatar_base64", "")).strip_edges(), true)
|
||||
var ownedSkinsVariant: Variant = profile.get("owned_skins", [])
|
||||
if ownedSkinsVariant is Array:
|
||||
apply_account_skin_assets(ownedSkinsVariant as Array)
|
||||
var ownedVariant: Variant = profile.get("owned_skin_ids", [])
|
||||
if ownedVariant is Array:
|
||||
set_owned_skin_ids(ownedVariant)
|
||||
if not skinId.is_empty():
|
||||
var shouldApplySkin := _pendingSkinId.is_empty() or skinId == _pendingSkinId
|
||||
if not skinId.is_empty() and shouldApplySkin:
|
||||
grant_owned_skin(skinId)
|
||||
set_selected_skin(skinId, true)
|
||||
else:
|
||||
_confirmedSkinId = skinId
|
||||
if skinId == _pendingSkinId:
|
||||
_pendingSkinId = ""
|
||||
elif skinId.is_empty() and _pendingSkinId.is_empty():
|
||||
var fallbackSkinId := _first_owned_skin_id()
|
||||
if selectedSkinId != fallbackSkinId:
|
||||
selectedSkinId = fallbackSkinId
|
||||
_save()
|
||||
_emit_skin_changed()
|
||||
_emit_profile_changed()
|
||||
_confirmedSkinId = fallbackSkinId
|
||||
_profileSyncSuppressed = false
|
||||
if _accountAvatarTexture != null or not _accountAvatarUrl.is_empty():
|
||||
selectedAvatarId = CUSTOM_AVATAR_ID
|
||||
@@ -306,7 +320,7 @@ func apply_account_avatar_base64(avatarUrl: String, avatarBase64: String) -> voi
|
||||
_accountAvatarUrl = _absolute_backend_url(avatarUrl.strip_edges())
|
||||
_customAvatarTexture = null
|
||||
_customAvatarActive = false
|
||||
_accountAvatarTexture = _texture_from_base64(avatarBase64)
|
||||
_accountAvatarTexture = _texture_from_base64(avatarBase64, true)
|
||||
if _accountAvatarTexture != null or not _accountAvatarUrl.is_empty():
|
||||
selectedAvatarId = CUSTOM_AVATAR_ID
|
||||
else:
|
||||
@@ -376,7 +390,7 @@ func has_custom_skin() -> bool:
|
||||
return _customSkinActive and _customSkinTexture != null
|
||||
|
||||
func get_custom_avatar_texture() -> Texture2D:
|
||||
if has_custom_avatar():
|
||||
if _customAvatarActive and _customAvatarTexture != null:
|
||||
return _customAvatarTexture
|
||||
return _accountAvatarTexture
|
||||
|
||||
@@ -715,11 +729,45 @@ func _sync_account_skin_profile() -> void:
|
||||
|
||||
func _connect_auth_events() -> void:
|
||||
var eventSystem := get_node_or_null("/root/EventSystem")
|
||||
if eventSystem == null:
|
||||
return
|
||||
if eventSystem != null:
|
||||
eventSystem.call("connect_event", EventNames.AUTH_LOGOUT, _on_auth_logout, self)
|
||||
var authManager := get_node_or_null("/root/AuthManager")
|
||||
if authManager != null and authManager.has_signal("appearance_update_succeeded"):
|
||||
authManager.connect("appearance_update_succeeded", _on_appearance_update_succeeded)
|
||||
if authManager != null and authManager.has_signal("appearance_update_failed"):
|
||||
authManager.connect("appearance_update_failed", _on_appearance_update_failed)
|
||||
|
||||
func _on_appearance_update_succeeded(requestedSkinId: String, profile: Dictionary) -> void:
|
||||
if requestedSkinId != _pendingSkinId:
|
||||
return
|
||||
var confirmedSkinId := str(profile.get("skin_id", requestedSkinId)).strip_edges()
|
||||
if confirmedSkinId.is_empty():
|
||||
confirmedSkinId = requestedSkinId
|
||||
_pendingSkinId = ""
|
||||
_confirmedSkinId = confirmedSkinId
|
||||
if selectedSkinId != confirmedSkinId:
|
||||
_apply_selected_skin_locally(confirmedSkinId)
|
||||
|
||||
func _on_appearance_update_failed(requestedSkinId: String, _message: String) -> void:
|
||||
if requestedSkinId != _pendingSkinId:
|
||||
return
|
||||
_pendingSkinId = ""
|
||||
if not _confirmedSkinId.is_empty() and selectedSkinId == requestedSkinId:
|
||||
_apply_selected_skin_locally(_confirmedSkinId)
|
||||
|
||||
func _apply_selected_skin_locally(skinId: String) -> void:
|
||||
selectedSkinId = skinId
|
||||
_save()
|
||||
_emit_skin_changed()
|
||||
_emit_profile_changed()
|
||||
|
||||
func _is_account_authenticated() -> bool:
|
||||
var authManager := get_node_or_null("/root/AuthManager")
|
||||
return authManager != null and authManager.has_method("is_authenticated") and bool(authManager.call("is_authenticated"))
|
||||
|
||||
func _on_auth_logout(_data: Variant = null) -> void:
|
||||
_pendingSkinId = ""
|
||||
_confirmedSkinId = FALLBACK_SKIN_ID
|
||||
clear_account_profile()
|
||||
|
||||
func get_profile_payload() -> Dictionary:
|
||||
@@ -762,11 +810,19 @@ func _get_or_create_avatar_texture_rect(panel: PanelContainer) -> TextureRect:
|
||||
textureRect.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
|
||||
textureRect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
textureRect.stretch_mode = TextureRect.STRETCH_SCALE
|
||||
textureRect.material = _create_avatar_mask_material()
|
||||
textureRect.custom_minimum_size = panel.custom_minimum_size
|
||||
textureRect.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
panel.add_child(textureRect)
|
||||
return textureRect
|
||||
|
||||
func _create_avatar_mask_material() -> ShaderMaterial:
|
||||
var material := ShaderMaterial.new()
|
||||
material.shader = AVATAR_MASK_SHADER
|
||||
material.set_shader_parameter("corner_radius", 0.22)
|
||||
material.set_shader_parameter("edge_feather", 0.008)
|
||||
return material
|
||||
|
||||
func _normalize_account_skin_asset(skin: Dictionary) -> Dictionary:
|
||||
var normalized := skin.duplicate(true)
|
||||
var skinId := str(normalized.get("id", normalized.get("skin_id", ""))).strip_edges()
|
||||
@@ -784,7 +840,7 @@ func _normalize_account_skin_asset(skin: Dictionary) -> Dictionary:
|
||||
normalized["account_asset"] = true
|
||||
return normalized
|
||||
|
||||
func _texture_from_base64(base64Text: String) -> Texture2D:
|
||||
func _texture_from_base64(base64Text: String, roundAvatar: bool = false) -> Texture2D:
|
||||
var bytes := Marshalls.base64_to_raw(base64Text.strip_edges())
|
||||
if bytes.is_empty():
|
||||
return null
|
||||
@@ -792,6 +848,9 @@ func _texture_from_base64(base64Text: String) -> Texture2D:
|
||||
var err := image.load_png_from_buffer(bytes)
|
||||
if err != OK:
|
||||
return null
|
||||
if roundAvatar:
|
||||
image = _create_square_avatar_image(image)
|
||||
_apply_rounded_rect_alpha(image)
|
||||
return ImageTexture.create_from_image(image)
|
||||
|
||||
func _absolute_backend_url(path: String) -> String:
|
||||
|
||||
@@ -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 不可用"})
|
||||
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
|
||||
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
|
||||
_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
|
||||
|
||||
if is_authenticated():
|
||||
_session_generation += 1
|
||||
_account_generation += 1
|
||||
if emit_cached_state:
|
||||
call_deferred("refresh_session")
|
||||
if emit_cached_state and is_authenticated():
|
||||
call_deferred("_emit_cached_auth_state")
|
||||
|
||||
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))
|
||||
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
|
||||
|
||||
@@ -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
|
||||
_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(error_info.get("message", "咖啡店陪聊请求失败")))
|
||||
_emit_chat_error(str(response.get("message", "咖啡店陪聊请求失败")))
|
||||
return
|
||||
|
||||
callback.call(response)
|
||||
, method, true, REQUEST_TIMEOUT)
|
||||
|
||||
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
|
||||
|
||||
@@ -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,6 +625,7 @@ func _send_chat_payload(content: String, scope: String = "local", extra_data: Di
|
||||
if send_err != OK:
|
||||
_handle_error("SEND_FAILED", "WebSocket send failed: %s" % error_string(send_err))
|
||||
return false
|
||||
if not non_recoverable:
|
||||
_track_recoverable_message({
|
||||
"content": normalized_content,
|
||||
"scope": scope,
|
||||
@@ -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,6 +1584,9 @@ func _handle_position_update(data: Dictionary) -> void:
|
||||
"userId": user_id,
|
||||
"username": str(data.get("username", "")),
|
||||
"position": Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))),
|
||||
"direction": str(data.get("direction", "down")),
|
||||
"movement_state": str(data.get("movementState", data.get("movement_state", "walk"))),
|
||||
"sequence": int(data.get("sequence", -1)),
|
||||
"mapId": str(data.get("mapId", data.get("map_id", ""))),
|
||||
"skin_id": str(appearance.get("skin_id", "")),
|
||||
"avatar_id": str(appearance.get("avatar_id", "")),
|
||||
@@ -1358,6 +1611,9 @@ func _handle_map_players_snapshot(data: Dictionary) -> void:
|
||||
"userId": user_id,
|
||||
"username": str(player.get("username", "")),
|
||||
"position": Vector2(float(player.get("x", 0.0)), float(player.get("y", 0.0))),
|
||||
"direction": str(player.get("direction", "down")),
|
||||
"movement_state": str(player.get("movementState", player.get("movement_state", "idle"))),
|
||||
"sequence": int(player.get("sequence", -1)),
|
||||
"mapId": str(player.get("mapId", player.get("map_id", data.get("mapId", data.get("map_id", ""))))),
|
||||
"skin_id": str(appearance.get("skin_id", "")),
|
||||
"avatar_id": str(appearance.get("avatar_id", "")),
|
||||
@@ -1371,6 +1627,72 @@ func _handle_map_players_snapshot(data: Dictionary) -> void:
|
||||
"players": players
|
||||
})
|
||||
|
||||
func _handle_npc_snapshot(data: Dictionary) -> void:
|
||||
var npcs: Array[Dictionary] = []
|
||||
var npcsVariant: Variant = data.get("npcs", [])
|
||||
if npcsVariant is Array:
|
||||
for npcVariant in npcsVariant:
|
||||
if not (npcVariant is Dictionary):
|
||||
continue
|
||||
var npc: Dictionary = npcVariant
|
||||
var npcId := str(npc.get("npcId", npc.get("npc_id", ""))).strip_edges()
|
||||
if npcId.is_empty():
|
||||
continue
|
||||
npcs.append({
|
||||
"npc_id": npcId,
|
||||
"name": str(npc.get("name", "NPC")),
|
||||
"x": float(npc.get("x", 0.0)),
|
||||
"y": float(npc.get("y", 0.0)),
|
||||
"direction": str(npc.get("direction", "down")),
|
||||
"movement_state": str(npc.get("movementState", npc.get("movement_state", "idle"))),
|
||||
"state": str(npc.get("state", "idle")),
|
||||
"version": int(npc.get("version", 0)),
|
||||
"daily_goal": str(npc.get("dailyGoal", npc.get("daily_goal", ""))),
|
||||
"plan_source": str(npc.get("planSource", npc.get("plan_source", "fallback"))),
|
||||
"current_activity": npc.get("currentActivity", npc.get("current_activity", {})),
|
||||
"public_intention": str(npc.get("publicIntention", npc.get("public_intention", ""))),
|
||||
"dialogue": str(npc.get("dialogue", "")),
|
||||
"scene": str(npc.get("scene", "classic_whale")),
|
||||
"active_action": _normalize_npc_action(npc.get("activeAction", npc.get("active_action", null))),
|
||||
})
|
||||
|
||||
_emit_event(EventNames.NPC_SNAPSHOT_READY, {
|
||||
"mapId": str(data.get("mapId", data.get("map_id", ""))),
|
||||
"server_now": int(data.get("serverNow", data.get("server_now", 0))),
|
||||
"version": int(data.get("version", 0)),
|
||||
"npcs": npcs,
|
||||
})
|
||||
|
||||
func _normalize_npc_action_event(data: Dictionary) -> Dictionary:
|
||||
return {
|
||||
"mapId": str(data.get("mapId", data.get("map_id", ""))),
|
||||
"server_now": int(data.get("serverNow", data.get("server_now", 0))),
|
||||
"npc_id": str(data.get("npcId", data.get("npc_id", ""))),
|
||||
"action": _normalize_npc_action(data.get("action", {})),
|
||||
}
|
||||
|
||||
func _normalize_npc_action(value: Variant) -> Dictionary:
|
||||
if not (value is Dictionary):
|
||||
return {}
|
||||
var action: Dictionary = value
|
||||
return {
|
||||
"action_id": str(action.get("actionId", action.get("action_id", ""))),
|
||||
"kind": str(action.get("kind", "walk")),
|
||||
"from_x": float(action.get("fromX", action.get("from_x", 0.0))),
|
||||
"from_y": float(action.get("fromY", action.get("from_y", 0.0))),
|
||||
"to_x": float(action.get("toX", action.get("to_x", 0.0))),
|
||||
"to_y": float(action.get("toY", action.get("to_y", 0.0))),
|
||||
"from_map_id": str(action.get("fromMapId", action.get("from_map_id", ""))),
|
||||
"to_map_id": str(action.get("toMapId", action.get("to_map_id", ""))),
|
||||
"from_location_id": str(action.get("fromLocationId", action.get("from_location_id", ""))),
|
||||
"to_location_id": str(action.get("toLocationId", action.get("to_location_id", ""))),
|
||||
"activity_id": str(action.get("activityId", action.get("activity_id", ""))),
|
||||
"activity_kind": str(action.get("activityKind", action.get("activity_kind", ""))),
|
||||
"started_at": int(action.get("startedAt", action.get("started_at", 0))),
|
||||
"completes_at": int(action.get("completesAt", action.get("completes_at", 0))),
|
||||
"version": int(action.get("version", 0)),
|
||||
}
|
||||
|
||||
func _handle_player_joined(data: Dictionary) -> void:
|
||||
var user_id := str(data.get("userId", data.get("user_id", ""))).strip_edges()
|
||||
if user_id.is_empty():
|
||||
@@ -1381,6 +1703,9 @@ func _handle_player_joined(data: Dictionary) -> void:
|
||||
"userId": user_id,
|
||||
"username": str(data.get("username", "")),
|
||||
"position": Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))),
|
||||
"direction": str(data.get("direction", "down")),
|
||||
"movement_state": str(data.get("movementState", data.get("movement_state", "idle"))),
|
||||
"sequence": int(data.get("sequence", -1)),
|
||||
"mapId": str(data.get("mapId", data.get("map_id", ""))),
|
||||
"skin_id": str(appearance.get("skin_id", "")),
|
||||
"avatar_id": str(appearance.get("avatar_id", "")),
|
||||
@@ -1392,8 +1717,11 @@ func _handle_player_joined(data: Dictionary) -> void:
|
||||
func _normalize_player_appearance(data: Dictionary) -> Dictionary:
|
||||
var appearanceVariant: Variant = data.get("appearance", {})
|
||||
var appearance: Dictionary = appearanceVariant if appearanceVariant is Dictionary else {}
|
||||
var skinId := str(data.get("skinId", data.get("skin_id", appearance.get("skinId", appearance.get("skin_id", ""))))).strip_edges()
|
||||
if skinId.is_empty() or skinId == "pending_initial_skin":
|
||||
skinId = "classic_whale"
|
||||
return {
|
||||
"skin_id": str(data.get("skinId", data.get("skin_id", appearance.get("skinId", appearance.get("skin_id", ""))))),
|
||||
"skin_id": skinId,
|
||||
"avatar_id": str(data.get("avatarId", data.get("avatar_id", appearance.get("avatarId", appearance.get("avatar_id", ""))))),
|
||||
"skin_asset": data.get("skinAsset", data.get("skin_asset", appearance.get("skinAsset", appearance.get("skin_asset", {})))),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
201
_Core/managers/InputFocusManager.gd
Normal file
@@ -0,0 +1,201 @@
|
||||
extends Node
|
||||
|
||||
const FOCUS_BORDER_COLOR := Color(0.12, 0.49, 0.82, 1.0)
|
||||
const CARET_COLOR := Color(0.04, 0.31, 0.60, 1.0)
|
||||
const SELECTION_COLOR := Color(0.25, 0.61, 0.91, 0.30)
|
||||
const STYLED_META := &"whaletown_input_focus_styled"
|
||||
const INITIAL_PLACEHOLDER_META := &"whaletown_initial_placeholder"
|
||||
const FOCUS_PLACEHOLDER_META := &"whaletown_focus_placeholder"
|
||||
const NATIVE_INPUT_ID := "whaletown-native-input"
|
||||
|
||||
var _javascript_callback: Variant
|
||||
var _native_inputs: Dictionary = {}
|
||||
var _active_native_input: Control
|
||||
var _bridge_initialized := false
|
||||
|
||||
func _ready() -> void:
|
||||
get_tree().node_added.connect(_on_node_added)
|
||||
call_deferred("_initialize_javascript_bridge")
|
||||
call_deferred("_decorate_existing_inputs")
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if not _bridge_initialized:
|
||||
_initialize_javascript_bridge()
|
||||
|
||||
func _initialize_javascript_bridge() -> void:
|
||||
if _bridge_initialized or OS.get_name() != "Web":
|
||||
return
|
||||
var window: Variant = JavaScriptBridge.get_interface("window")
|
||||
if window == null:
|
||||
return
|
||||
_javascript_callback = JavaScriptBridge.create_callback(_on_native_input_event)
|
||||
window["__whaletownNativeInputCallback"] = _javascript_callback
|
||||
_bridge_initialized = true
|
||||
|
||||
func _exit_tree() -> void:
|
||||
_close_native_input()
|
||||
if _bridge_initialized:
|
||||
var window: Variant = JavaScriptBridge.get_interface("window")
|
||||
if window != null:
|
||||
window["__whaletownNativeInputCallback"] = null
|
||||
|
||||
func _on_node_added(node: Node) -> void:
|
||||
if node is LineEdit or node is TextEdit:
|
||||
_decorate_input.call_deferred(node)
|
||||
|
||||
func _decorate_existing_inputs() -> void:
|
||||
_decorate_branch(get_tree().root)
|
||||
|
||||
func _decorate_branch(node: Node) -> void:
|
||||
if node is LineEdit or node is TextEdit:
|
||||
_decorate_input(node)
|
||||
for child in node.get_children():
|
||||
_decorate_branch(child)
|
||||
|
||||
func _decorate_input(input: Control) -> void:
|
||||
if not is_instance_valid(input) or input.has_meta(STYLED_META):
|
||||
return
|
||||
input.set_meta(STYLED_META, true)
|
||||
input.add_theme_stylebox_override("focus", _create_focus_style(input))
|
||||
input.add_theme_color_override("caret_color", CARET_COLOR)
|
||||
input.add_theme_color_override("selection_color", SELECTION_COLOR)
|
||||
input.add_theme_constant_override("caret_width", 2)
|
||||
input.set_meta(INITIAL_PLACEHOLDER_META, input.placeholder_text)
|
||||
input.focus_entered.connect(_on_input_focus_entered.bind(input))
|
||||
input.focus_exited.connect(_on_input_focus_exited.bind(input))
|
||||
if input is LineEdit:
|
||||
(input as LineEdit).text_changed.connect(func(_value: String) -> void: _sync_native_input(input))
|
||||
else:
|
||||
(input as TextEdit).text_changed.connect(func() -> void: _sync_native_input(input))
|
||||
|
||||
func _on_input_focus_entered(input: Control) -> void:
|
||||
if not is_instance_valid(input):
|
||||
return
|
||||
if input.text.strip_edges().is_empty():
|
||||
input.set_meta(FOCUS_PLACEHOLDER_META, input.placeholder_text)
|
||||
input.placeholder_text = ""
|
||||
_open_native_input(input)
|
||||
|
||||
func _on_input_focus_exited(input: Control) -> void:
|
||||
if not is_instance_valid(input) or not input.text.strip_edges().is_empty():
|
||||
return
|
||||
var placeholder := str(input.get_meta(FOCUS_PLACEHOLDER_META, input.get_meta(INITIAL_PLACEHOLDER_META, "")))
|
||||
if not placeholder.is_empty():
|
||||
input.placeholder_text = placeholder
|
||||
|
||||
func _open_native_input(input: Control) -> void:
|
||||
if not _bridge_initialized or not is_instance_valid(input):
|
||||
return
|
||||
_close_native_input()
|
||||
_active_native_input = input
|
||||
_native_inputs[input.get_instance_id()] = input
|
||||
var callback_name := "window.__whaletownNativeInputCallback"
|
||||
var input_id := str(input.get_instance_id())
|
||||
var initial_text: String = input.text
|
||||
var multiline := input is TextEdit
|
||||
var secret := input is LineEdit and (input as LineEdit).secret
|
||||
var rect := input.get_global_rect()
|
||||
var viewport_size := get_viewport().get_visible_rect().size
|
||||
var script := """
|
||||
(() => {
|
||||
const callback = %s;
|
||||
if (typeof callback !== 'function') return false;
|
||||
const previous = document.getElementById('%s');
|
||||
if (previous) previous.remove();
|
||||
const target = document.createElement(%s ? 'textarea' : 'input');
|
||||
target.id = '%s';
|
||||
target.value = %s;
|
||||
target.spellcheck = false;
|
||||
target.autocomplete = 'off';
|
||||
if (%s) target.type = 'password';
|
||||
const rect = {x: %f, y: %f, width: %f, height: %f};
|
||||
const viewport = {width: %f, height: %f};
|
||||
const canvas = document.getElementById('canvas');
|
||||
const position = () => {
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
const sx = bounds.width / viewport.width;
|
||||
const sy = bounds.height / viewport.height;
|
||||
target.style.left = (bounds.left + rect.x * sx) + 'px';
|
||||
target.style.top = (bounds.top + rect.y * sy) + 'px';
|
||||
target.style.width = (rect.width * sx) + 'px';
|
||||
target.style.height = (rect.height * sy) + 'px';
|
||||
};
|
||||
Object.assign(target.style, {
|
||||
position: 'fixed', zIndex: '10000',
|
||||
opacity: '0.01', color: 'transparent', caretColor: 'transparent',
|
||||
background: 'transparent', border: '0', outline: '0', padding: '0',
|
||||
margin: '0', resize: 'none', WebkitTextFillColor: 'transparent'
|
||||
});
|
||||
const send = (kind) => callback(kind, %s, target.value,
|
||||
target.selectionStart || 0, target.selectionEnd || 0);
|
||||
target.addEventListener('input', () => send('input'));
|
||||
target.addEventListener('blur', () => send('blur'));
|
||||
target.addEventListener('keydown', (event) => {
|
||||
if (!%s && event.key === 'Enter') { event.preventDefault(); send('submit'); }
|
||||
if (event.key === 'Escape') { event.preventDefault(); send('cancel'); }
|
||||
});
|
||||
document.querySelectorAll('.ime').forEach((element) => element.remove());
|
||||
document.body.appendChild(target);
|
||||
position();
|
||||
window.addEventListener('resize', position, {passive: true});
|
||||
target.__whaletownPosition = position;
|
||||
target.focus({preventScroll: true});
|
||||
target.setSelectionRange(target.value.length, target.value.length);
|
||||
return true;
|
||||
})()
|
||||
""" % [callback_name, NATIVE_INPUT_ID, str(multiline).to_lower(), NATIVE_INPUT_ID,
|
||||
JSON.stringify(initial_text), str(secret).to_lower(), rect.position.x, rect.position.y,
|
||||
rect.size.x, rect.size.y, viewport_size.x, viewport_size.y, JSON.stringify(input_id), str(multiline).to_lower()]
|
||||
var opened: Variant = JavaScriptBridge.eval(script, true)
|
||||
if opened != true:
|
||||
_native_inputs.erase(input.get_instance_id())
|
||||
_active_native_input = null
|
||||
|
||||
func _close_native_input() -> void:
|
||||
if _bridge_initialized:
|
||||
JavaScriptBridge.eval("(() => { const el = document.getElementById('%s'); if (el) { if (el.__whaletownPosition) window.removeEventListener('resize', el.__whaletownPosition); el.remove(); } })()" % NATIVE_INPUT_ID, true)
|
||||
_native_inputs.clear()
|
||||
_active_native_input = null
|
||||
|
||||
func _sync_native_input(input: Control) -> void:
|
||||
if not _bridge_initialized or input != _active_native_input or not is_instance_valid(input):
|
||||
return
|
||||
var value: String = input.text
|
||||
var script := "(() => { const el = document.getElementById('%s'); if (el && el.value !== %s) el.value = %s; })()" % [NATIVE_INPUT_ID, JSON.stringify(value), JSON.stringify(value)]
|
||||
JavaScriptBridge.eval(script, true)
|
||||
|
||||
func _on_native_input_event(arguments: Array) -> void:
|
||||
if arguments.size() < 3:
|
||||
return
|
||||
var event_kind := str(arguments[0])
|
||||
var input_id := int(arguments[1])
|
||||
var input := _native_inputs.get(input_id) as Control
|
||||
if not is_instance_valid(input):
|
||||
return
|
||||
var value := str(arguments[2])
|
||||
if input.text != value:
|
||||
input.text = value
|
||||
if input is LineEdit:
|
||||
(input as LineEdit).set_caret_column(value.length())
|
||||
match event_kind:
|
||||
"submit":
|
||||
if input is LineEdit:
|
||||
(input as LineEdit).text_submitted.emit(input.text)
|
||||
"cancel":
|
||||
_close_native_input()
|
||||
|
||||
func _create_focus_style(input: Control) -> StyleBoxFlat:
|
||||
var focusStyle := StyleBoxFlat.new()
|
||||
focusStyle.bg_color = Color.TRANSPARENT
|
||||
focusStyle.border_color = FOCUS_BORDER_COLOR
|
||||
focusStyle.set_border_width_all(2)
|
||||
|
||||
var normalStyle := input.get_theme_stylebox("normal") as StyleBoxFlat
|
||||
if normalStyle != null:
|
||||
focusStyle.corner_radius_top_left = normalStyle.corner_radius_top_left
|
||||
focusStyle.corner_radius_top_right = normalStyle.corner_radius_top_right
|
||||
focusStyle.corner_radius_bottom_right = normalStyle.corner_radius_bottom_right
|
||||
focusStyle.corner_radius_bottom_left = normalStyle.corner_radius_bottom_left
|
||||
else:
|
||||
focusStyle.set_corner_radius_all(6)
|
||||
return focusStyle
|
||||
1
_Core/managers/InputFocusManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://lg8ud3kgjliv
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
uid://cg46uuk3hxvqe
|
||||
@@ -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)
|
||||
return
|
||||
_retry_or_fail(message)
|
||||
if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300:
|
||||
_retry_or_fail(_read_error_message(body, "家具位置保存失败"))
|
||||
return
|
||||
|
||||
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)
|
||||
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
|
||||
var dataVariant: Variant = response.get("data", {})
|
||||
if dataVariant is Dictionary:
|
||||
savedItem = (dataVariant as Dictionary).duplicate(true)
|
||||
|
||||
_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
|
||||
|
||||
@@ -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
|
||||
|
||||
# ============ 场景注册方法 ============
|
||||
|
||||
# 注册新场景
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "轻松闲聊"},
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
uid://cm5em8mgr5i24
|
||||
@@ -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()
|
||||
@@ -1 +0,0 @@
|
||||
uid://byurfu7d8qyfb
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
uid://cntd1faiax3br
|
||||
26
_Core/managers/UIFontManager.gd
Normal file
@@ -0,0 +1,26 @@
|
||||
extends Node
|
||||
|
||||
# Enforce a Chinese-capable font for every UI control, including controls
|
||||
# constructed dynamically after their scene has loaded.
|
||||
const UI_FONT: FontFile = preload("res://assets/fonts/msyh-web.ttf")
|
||||
|
||||
func _enter_tree() -> void:
|
||||
get_tree().node_added.connect(_on_node_added)
|
||||
_apply_font_recursively(get_tree().root)
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var node_added_callback := Callable(self, "_on_node_added")
|
||||
if get_tree().node_added.is_connected(node_added_callback):
|
||||
get_tree().node_added.disconnect(node_added_callback)
|
||||
|
||||
func _on_node_added(node: Node) -> void:
|
||||
_apply_font_recursively(node)
|
||||
|
||||
func _apply_font_recursively(node: Node) -> void:
|
||||
if node is Control:
|
||||
var control := node as Control
|
||||
control.add_theme_font_override(&"font", UI_FONT)
|
||||
if control is RichTextLabel:
|
||||
control.add_theme_font_override(&"normal_font", UI_FONT)
|
||||
for child in node.get_children():
|
||||
_apply_font_recursively(child)
|
||||
1
_Core/managers/UIFontManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cvb5smbfhqnfc
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
uid://otx02vxmfni3
|
||||
@@ -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 "错误"
|
||||
_:
|
||||
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
uid://djp28p0cx377d
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
uid://bvmpisyit53q0
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
uid://bqd3tfgvlhj8r
|
||||
@@ -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)
|
||||
@@ -1 +0,0 @@
|
||||
uid://de27p7wr4gj2b
|
||||
@@ -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():
|
||||
|
||||
123
_Core/utils/WebFilePicker.gd
Normal file
@@ -0,0 +1,123 @@
|
||||
extends RefCounted
|
||||
|
||||
var _javascript_callback: Variant
|
||||
var _result_callback: Callable
|
||||
var _callback_name: String = ""
|
||||
|
||||
func open(accept: String, max_bytes: int, result_callback: Callable) -> bool:
|
||||
if OS.get_name() != "Web" or not Engine.has_singleton("JavaScriptBridge"):
|
||||
return false
|
||||
if _result_callback.is_valid():
|
||||
return false
|
||||
|
||||
var javascript_bridge: Object = Engine.get_singleton("JavaScriptBridge")
|
||||
_callback_name = "__whaletownFilePicker%d" % get_instance_id()
|
||||
_result_callback = result_callback
|
||||
_javascript_callback = javascript_bridge.create_callback(_on_javascript_result)
|
||||
var window: Variant = javascript_bridge.get_interface("window")
|
||||
if window == null:
|
||||
_reset()
|
||||
return false
|
||||
window[_callback_name] = _javascript_callback
|
||||
|
||||
var script := """
|
||||
(() => {
|
||||
const callbackName = %s;
|
||||
const callback = window[callbackName];
|
||||
if (typeof callback !== "function") return false;
|
||||
|
||||
const previous = document.getElementById("whaletown-web-file-picker");
|
||||
if (previous) previous.remove();
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.id = "whaletown-web-file-picker";
|
||||
input.type = "file";
|
||||
input.accept = %s;
|
||||
input.style.display = "none";
|
||||
let finished = false;
|
||||
|
||||
const finish = (status, name, mime, payload) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
callback(status, name || "", mime || "", payload || "");
|
||||
input.remove();
|
||||
delete window[callbackName];
|
||||
};
|
||||
|
||||
input.addEventListener("cancel", () => finish("cancel", "", "", ""));
|
||||
input.addEventListener("change", () => {
|
||||
const file = input.files && input.files[0];
|
||||
if (!file) {
|
||||
finish("cancel", "", "", "");
|
||||
return;
|
||||
}
|
||||
if (file.size > %d) {
|
||||
finish("too_large", file.name, file.type, "");
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => finish("read_error", file.name, file.type, "");
|
||||
reader.onload = () => {
|
||||
const bytes = new Uint8Array(reader.result);
|
||||
const chunks = [];
|
||||
const chunkSize = 0x8000;
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)));
|
||||
}
|
||||
finish("ok", file.name, file.type, btoa(chunks.join("")));
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
|
||||
document.body.appendChild(input);
|
||||
input.click();
|
||||
return true;
|
||||
})()
|
||||
""" % [JSON.stringify(_callback_name), JSON.stringify(accept), max_bytes]
|
||||
var opened: Variant = javascript_bridge.eval(script, true)
|
||||
if opened != true:
|
||||
_reset()
|
||||
return false
|
||||
return true
|
||||
|
||||
func _on_javascript_result(arguments: Array) -> void:
|
||||
var callback := _result_callback
|
||||
_reset()
|
||||
if not callback.is_valid():
|
||||
return
|
||||
var status := str(arguments[0]) if arguments.size() > 0 else "read_error"
|
||||
var file_name := str(arguments[1]) if arguments.size() > 1 else ""
|
||||
var mime_type := str(arguments[2]) if arguments.size() > 2 else ""
|
||||
var base64_data := str(arguments[3]) if arguments.size() > 3 else ""
|
||||
callback.call(status, file_name, mime_type, base64_data)
|
||||
|
||||
func _reset() -> void:
|
||||
_javascript_callback = null
|
||||
_result_callback = Callable()
|
||||
_callback_name = ""
|
||||
|
||||
static func save_base64_file(base64_data: String, file_name: String, prefix: String) -> String:
|
||||
if base64_data.is_empty():
|
||||
return ""
|
||||
var bytes := Marshalls.base64_to_raw(base64_data)
|
||||
if bytes.is_empty():
|
||||
return ""
|
||||
|
||||
var extension := file_name.get_extension().to_lower()
|
||||
if not ["png", "jpg", "jpeg", "webp"].has(extension):
|
||||
extension = "bin"
|
||||
var root := DirAccess.open("user://")
|
||||
if root == null:
|
||||
return ""
|
||||
if not root.dir_exists("web_uploads") and root.make_dir("web_uploads") != OK:
|
||||
return ""
|
||||
|
||||
var safe_prefix := prefix.to_lower().replace(" ", "_")
|
||||
var path := "user://web_uploads/%s_%d.%s" % [safe_prefix, Time.get_ticks_msec(), extension]
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return ""
|
||||
file.store_buffer(bytes)
|
||||
file.close()
|
||||
return path
|
||||
1
_Core/utils/WebFilePicker.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dqvrn7yv0ap4y
|
||||
|
After Width: | Height: | Size: 226 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dku0dqqhtnjbk"
|
||||
path="res://.godot/imported/horned_creature_npc_idle_down.png-fae6e26bf555f707761f7ec796a16f56.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_down.png"
|
||||
dest_files=["res://.godot/imported/horned_creature_npc_idle_down.png-fae6e26bf555f707761f7ec796a16f56.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 165 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bgwcow6lkrb6c"
|
||||
path="res://.godot/imported/horned_creature_npc_idle_left.png-80f388f72940bd5985d973d135215c97.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_left.png"
|
||||
dest_files=["res://.godot/imported/horned_creature_npc_idle_left.png-80f388f72940bd5985d973d135215c97.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 177 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cmf8t7qsogtkv"
|
||||
path="res://.godot/imported/horned_creature_npc_idle_right.png-98efb444bb786263b2f3dd1d1241be34.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_right.png"
|
||||
dest_files=["res://.godot/imported/horned_creature_npc_idle_right.png-98efb444bb786263b2f3dd1d1241be34.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 198 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bv70hix5m85sf"
|
||||
path="res://.godot/imported/horned_creature_npc_idle_up.png-aa780407ff47d0ba8647dab1272956e4.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_up.png"
|
||||
dest_files=["res://.godot/imported/horned_creature_npc_idle_up.png-aa780407ff47d0ba8647dab1272956e4.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 784 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://b8ogjmo7jx5x5"
|
||||
path="res://.godot/imported/horned_creature_npc_spritesheet.png-443e96d87eae79edde2818f42c4ec378.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/characters/generated/horned_creature_npc/horned_creature_npc_spritesheet.png"
|
||||
dest_files=["res://.godot/imported/horned_creature_npc_spritesheet.png-443e96d87eae79edde2818f42c4ec378.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 290 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://i2qjcms43oc0"
|
||||
path="res://.godot/imported/whale_researcher_no_feet_spritesheet.png-828a36866400f9adbedeaaedec6e718f.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/characters/generated/whale_researcher_v2/final_no_feet/processed/whale_researcher_no_feet_spritesheet.png"
|
||||
dest_files=["res://.godot/imported/whale_researcher_no_feet_spritesheet.png-828a36866400f9adbedeaaedec6e718f.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
Before Width: | Height: | Size: 398 KiB After Width: | Height: | Size: 576 KiB |
96
assets/fonts/fusion-pixel-12px/OFL.txt
Normal file
@@ -0,0 +1,96 @@
|
||||
Fusion Pixel Font
|
||||
https://github.com/TakWolf/fusion-pixel-font
|
||||
|
||||
Copyright (c) 2022, TakWolf (https://takwolf.com).
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,36 @@
|
||||
[remap]
|
||||
|
||||
importer="font_data_dynamic"
|
||||
type="FontFile"
|
||||
uid="uid://dss1envknmwvy"
|
||||
path="res://.godot/imported/fusion-pixel-12px-proportional-latin.ttf.woff2-d7baa0634e626d804d8eea7c4b5e5637.fontdata"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2"
|
||||
dest_files=["res://.godot/imported/fusion-pixel-12px-proportional-latin.ttf.woff2-d7baa0634e626d804d8eea7c4b5e5637.fontdata"]
|
||||
|
||||
[params]
|
||||
|
||||
Rendering=null
|
||||
antialiasing=1
|
||||
generate_mipmaps=false
|
||||
disable_embedded_bitmaps=true
|
||||
multichannel_signed_distance_field=false
|
||||
msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=1
|
||||
subpixel_positioning=4
|
||||
keep_rounding_remainders=true
|
||||
oversampling=0.0
|
||||
Fallbacks=null
|
||||
fallbacks=[]
|
||||
Compress=null
|
||||
compress=true
|
||||
preload=[]
|
||||
language_support={}
|
||||
script_support={}
|
||||
opentype_features={}
|
||||
@@ -0,0 +1,36 @@
|
||||
[remap]
|
||||
|
||||
importer="font_data_dynamic"
|
||||
type="FontFile"
|
||||
uid="uid://cea60eanpeue4"
|
||||
path="res://.godot/imported/fusion-pixel-12px-proportional-zh_hans.ttf.woff2-3005ce2bd956afbf78a39ebd1102f9dc.fontdata"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2"
|
||||
dest_files=["res://.godot/imported/fusion-pixel-12px-proportional-zh_hans.ttf.woff2-3005ce2bd956afbf78a39ebd1102f9dc.fontdata"]
|
||||
|
||||
[params]
|
||||
|
||||
Rendering=null
|
||||
antialiasing=1
|
||||
generate_mipmaps=false
|
||||
disable_embedded_bitmaps=true
|
||||
multichannel_signed_distance_field=false
|
||||
msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=1
|
||||
subpixel_positioning=4
|
||||
keep_rounding_remainders=true
|
||||
oversampling=0.0
|
||||
Fallbacks=null
|
||||
fallbacks=[]
|
||||
Compress=null
|
||||
compress=true
|
||||
preload=[]
|
||||
language_support={}
|
||||
script_support={}
|
||||
opentype_features={}
|
||||
BIN
assets/fonts/msyh-web.ttf
Normal file
36
assets/fonts/msyh-web.ttf.import
Normal file
@@ -0,0 +1,36 @@
|
||||
[remap]
|
||||
|
||||
importer="font_data_dynamic"
|
||||
type="FontFile"
|
||||
uid="uid://cy7uxq32lpgou"
|
||||
path="res://.godot/imported/msyh-web.ttf-0b9bbf1e573e29d7e152628e09746417.fontdata"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/fonts/msyh-web.ttf"
|
||||
dest_files=["res://.godot/imported/msyh-web.ttf-0b9bbf1e573e29d7e152628e09746417.fontdata"]
|
||||
|
||||
[params]
|
||||
|
||||
Rendering=null
|
||||
antialiasing=1
|
||||
generate_mipmaps=false
|
||||
disable_embedded_bitmaps=true
|
||||
multichannel_signed_distance_field=false
|
||||
msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=1
|
||||
subpixel_positioning=4
|
||||
keep_rounding_remainders=true
|
||||
oversampling=0.0
|
||||
Fallbacks=null
|
||||
fallbacks=[]
|
||||
Compress=null
|
||||
compress=true
|
||||
preload=[]
|
||||
language_support={}
|
||||
script_support={}
|
||||
opentype_features={}
|
||||
BIN
assets/maps/cafe/whale_cafe_large_service_hall_base_v4.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cw2ywnvsjk4tw"
|
||||
path="res://.godot/imported/whale_cafe_large_service_hall_base_v4.png-a01976399336db42367bf3da210ce89b.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/maps/cafe/whale_cafe_large_service_hall_base_v4.png"
|
||||
dest_files=["res://.godot/imported/whale_cafe_large_service_hall_base_v4.png-a01976399336db42367bf3da210ce89b.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
15
assets/shaders/avatar_round_mask.gdshader
Normal file
@@ -0,0 +1,15 @@
|
||||
shader_type canvas_item;
|
||||
|
||||
uniform float corner_radius = 0.22;
|
||||
uniform float edge_feather = 0.008;
|
||||
|
||||
void fragment() {
|
||||
vec2 q = abs(UV - vec2(0.5)) - (vec2(0.5) - vec2(corner_radius));
|
||||
float outside_distance = length(max(q, vec2(0.0)));
|
||||
float inside_distance = min(max(q.x, q.y), 0.0);
|
||||
float signed_distance = outside_distance + inside_distance - corner_radius;
|
||||
float mask = 1.0 - smoothstep(0.0, edge_feather, signed_distance);
|
||||
|
||||
COLOR = texture(TEXTURE, UV);
|
||||
COLOR.a *= mask;
|
||||
}
|
||||
1
assets/shaders/avatar_round_mask.gdshader.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bkudybm8sbvug
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 58 KiB |
BIN
assets/ui/world_bulletin/community/empty_whale.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
40
assets/ui/world_bulletin/community/empty_whale.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://eq2fin4oby2k"
|
||||
path="res://.godot/imported/empty_whale.png-ffea64aa04d74a02a6935a88182cee3c.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/ui/world_bulletin/community/empty_whale.png"
|
||||
dest_files=["res://.godot/imported/empty_whale.png-ffea64aa04d74a02a6935a88182cee3c.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=true
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
BIN
assets/ui/world_bulletin/community/pinned_note.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
40
assets/ui/world_bulletin/community/pinned_note.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bx5uy1p64o4vo"
|
||||
path="res://.godot/imported/pinned_note.png-034bf127b63da4f239b070c415812df8.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/ui/world_bulletin/community/pinned_note.png"
|
||||
dest_files=["res://.godot/imported/pinned_note.png-034bf127b63da4f239b070c415812df8.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=true
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
BIN
assets/ui/world_bulletin/community/recruit_whales.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
40
assets/ui/world_bulletin/community/recruit_whales.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://b0w5mutlqnlxw"
|
||||
path="res://.godot/imported/recruit_whales.png-d87526a4f19c9d60855712b106907786.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/ui/world_bulletin/community/recruit_whales.png"
|
||||
dest_files=["res://.godot/imported/recruit_whales.png-d87526a4f19c9d60855712b106907786.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=true
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
BIN
assets/ui/world_bulletin/community/town_banner.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
40
assets/ui/world_bulletin/community/town_banner.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bk6gkltoavsd"
|
||||
path="res://.godot/imported/town_banner.png-ee7512df5a2c66f421bc751ceff6e080.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/ui/world_bulletin/community/town_banner.png"
|
||||
dest_files=["res://.godot/imported/town_banner.png-ee7512df5a2c66f421bc751ceff6e080.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=true
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 1.0 MiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://xpkmqb81fjw6"
|
||||
path="res://.godot/imported/world_bulletin_icon_hd_simple_tight.png-b97116e82ce39ccf8dbc8ba2be158537.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/ui/world_bulletin/community/world_bulletin_icon_hd_simple_tight.png"
|
||||
dest_files=["res://.godot/imported/world_bulletin_icon_hd_simple_tight.png-b97116e82ce39ccf8dbc8ba2be158537.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=true
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 41 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://s42gvnli8h2e"
|
||||
path="res://.godot/imported/world_bulletin_send_button_final_192.png-8db1899e79e2361bfc869a0da3dc8d51.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/ui/world_bulletin/world_bulletin_send_button_final_192.png"
|
||||
dest_files=["res://.godot/imported/world_bulletin_send_button_final_192.png-8db1899e79e2361bfc869a0da3dc8d51.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=true
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
BIN
assets/ui/world_bulletin/world_bulletin_send_v2_128.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://2ukykf5vywnj"
|
||||
path="res://.godot/imported/world_bulletin_send_v2_128.png-e1f7806cd2d6195a5af01a8fcf36310f.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/ui/world_bulletin/world_bulletin_send_v2_128.png"
|
||||
dest_files=["res://.godot/imported/world_bulletin_send_v2_128.png-e1f7806cd2d6195a5af01a8fcf36310f.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@@ -1,6 +1,6 @@
|
||||
[gd_resource type="Theme" format=3 uid="uid://brk6ca2npglqc"]
|
||||
|
||||
[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
@@ -0,0 +1,24 @@
|
||||
whaletown.novamailio.com {
|
||||
root * /var/www/whale-town-front-v2/build/site
|
||||
|
||||
@playWithoutSlash path /play
|
||||
@wasm path *.wasm
|
||||
@pack path *.pck
|
||||
redir @playWithoutSlash /play/ 308
|
||||
|
||||
handle_path /play/* {
|
||||
root * /var/www/whale-town-front-v2/build/site/play
|
||||
header @wasm Content-Type application/wasm
|
||||
header @pack Content-Type application/octet-stream
|
||||
file_server
|
||||
}
|
||||
|
||||
handle /downloads/* {
|
||||
header Content-Disposition "attachment"
|
||||
file_server
|
||||
}
|
||||
|
||||
handle {
|
||||
file_server
|
||||
}
|
||||
}
|
||||
129
deploy/Caddyfile.production
Normal file
@@ -0,0 +1,129 @@
|
||||
# Existing Sub2API and blog sites on the Singapore host.
|
||||
api.novamailio.com {
|
||||
@long_stream path /v1/responses /responses
|
||||
|
||||
handle @long_stream {
|
||||
reverse_proxy 127.0.0.1:8080 {
|
||||
flush_interval -1
|
||||
stream_close_delay 5m
|
||||
transport http {
|
||||
keepalive 30s
|
||||
keepalive_interval 15s
|
||||
keepalive_idle_conns_per_host 128
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
}
|
||||
|
||||
xiangking.novamailio.com {
|
||||
root * /var/www/xiangking-blog
|
||||
encode zstd gzip
|
||||
|
||||
@assets path /assets/*
|
||||
header @assets Cache-Control "public, max-age=31536000, immutable"
|
||||
|
||||
file_server
|
||||
}
|
||||
|
||||
whaletown.novamailio.com {
|
||||
log {
|
||||
output file /var/log/caddy/whaletown-access.json {
|
||||
roll_size 100MiB
|
||||
roll_keep 10
|
||||
roll_keep_for 720h
|
||||
}
|
||||
format json
|
||||
}
|
||||
|
||||
encode zstd gzip
|
||||
root * /var/www/whaletown/site
|
||||
|
||||
header {
|
||||
X-Content-Type-Options nosniff
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
}
|
||||
|
||||
@immutable_pack {
|
||||
path /play/packs/*.pck
|
||||
file {path}
|
||||
}
|
||||
header @immutable_pack {
|
||||
Cache-Control "public, max-age=31536000, immutable"
|
||||
Content-Type "application/octet-stream"
|
||||
}
|
||||
|
||||
@immutable_admin path /admin/assets/*
|
||||
header @immutable_admin Cache-Control "public, max-age=31536000, immutable"
|
||||
|
||||
@pack_manifest path /play/packs/manifest.json
|
||||
header @pack_manifest Cache-Control "no-cache"
|
||||
|
||||
@runtime path /play/index.js /play/index.wasm /play/index-*.pck /play/index.audio*.js
|
||||
header @runtime Cache-Control "public, max-age=14400"
|
||||
|
||||
@html path / /index.html /play /play/ /play/index.html /admin /admin/ /admin/index.html
|
||||
header @html Cache-Control "no-cache"
|
||||
|
||||
@play_without_slash path /play
|
||||
redir @play_without_slash /play/ 308
|
||||
|
||||
handle_path /play/* {
|
||||
root * /var/www/whaletown/site/play
|
||||
file_server
|
||||
}
|
||||
|
||||
@game path /game*
|
||||
handle @game {
|
||||
reverse_proxy http://124.221.77.216 {
|
||||
header_up Host 124.221.77.216
|
||||
}
|
||||
}
|
||||
|
||||
@location path /location-broadcast
|
||||
handle @location {
|
||||
reverse_proxy http://124.221.77.216 {
|
||||
header_up Host 124.221.77.216
|
||||
}
|
||||
}
|
||||
|
||||
@notice path /ws/notice
|
||||
handle @notice {
|
||||
reverse_proxy http://124.221.77.216 {
|
||||
header_up Host 124.221.77.216
|
||||
}
|
||||
}
|
||||
|
||||
@account_assets path /assets/account/*
|
||||
handle @account_assets {
|
||||
reverse_proxy http://124.221.77.216 {
|
||||
header_up Host 124.221.77.216
|
||||
}
|
||||
}
|
||||
|
||||
redir /api /api/
|
||||
handle /api/* {
|
||||
reverse_proxy http://124.221.77.216 {
|
||||
header_up Host 124.221.77.216
|
||||
}
|
||||
}
|
||||
|
||||
redir /admin /admin/
|
||||
handle_path /admin/* {
|
||||
root * /var/www/whaletown/admin
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
|
||||
handle /downloads/* {
|
||||
header Content-Disposition "attachment"
|
||||
file_server
|
||||
}
|
||||
|
||||
handle {
|
||||
file_server
|
||||
}
|
||||
}
|
||||
89
deploy/Caddyfile.rainyun-novamailio
Normal file
@@ -0,0 +1,89 @@
|
||||
whaletown.novamailio.com {
|
||||
log {
|
||||
output file /var/log/caddy/whaletown-access.json {
|
||||
roll_size 20MiB
|
||||
roll_keep 5
|
||||
roll_keep_for 168h
|
||||
}
|
||||
format json
|
||||
}
|
||||
|
||||
encode zstd gzip
|
||||
root * /var/www/whaletown/site
|
||||
|
||||
header {
|
||||
X-Content-Type-Options nosniff
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
}
|
||||
|
||||
@play path /play/*
|
||||
header @play {
|
||||
Cross-Origin-Opener-Policy same-origin
|
||||
Cross-Origin-Embedder-Policy require-corp
|
||||
}
|
||||
|
||||
@immutable_pack {
|
||||
path /play/packs/*.pck
|
||||
file {path}
|
||||
}
|
||||
header @immutable_pack {
|
||||
Cache-Control "public, max-age=31536000, immutable"
|
||||
Content-Type "application/octet-stream"
|
||||
}
|
||||
|
||||
@pack_manifest path /play/packs/manifest.json
|
||||
header @pack_manifest Cache-Control "no-cache"
|
||||
|
||||
@runtime path /play/index.js /play/index.wasm /play/index-*.pck /play/index.audio*.js
|
||||
header @runtime Cache-Control "public, max-age=14400"
|
||||
|
||||
@html path / /index.html /play /play/ /play/index.html
|
||||
header @html Cache-Control "no-cache"
|
||||
|
||||
@play_without_slash path /play
|
||||
redir @play_without_slash /play/ 308
|
||||
|
||||
handle_path /play/* {
|
||||
root * /var/www/whaletown/site/play
|
||||
file_server
|
||||
}
|
||||
|
||||
@game path /game*
|
||||
handle @game {
|
||||
reverse_proxy http://124.221.77.216 {
|
||||
header_up Host 124.221.77.216
|
||||
}
|
||||
}
|
||||
|
||||
@location path /location-broadcast
|
||||
handle @location {
|
||||
reverse_proxy http://124.221.77.216 {
|
||||
header_up Host 124.221.77.216
|
||||
}
|
||||
}
|
||||
|
||||
@notice path /ws/notice
|
||||
handle @notice {
|
||||
reverse_proxy http://124.221.77.216 {
|
||||
header_up Host 124.221.77.216
|
||||
}
|
||||
}
|
||||
|
||||
@account_assets path /assets/account/*
|
||||
handle @account_assets {
|
||||
reverse_proxy http://124.221.77.216 {
|
||||
header_up Host 124.221.77.216
|
||||
}
|
||||
}
|
||||
|
||||
redir /api /api/
|
||||
handle /api/* {
|
||||
reverse_proxy http://124.221.77.216 {
|
||||
header_up Host 124.221.77.216
|
||||
}
|
||||
}
|
||||
|
||||
handle {
|
||||
file_server
|
||||
}
|
||||
}
|
||||
89
deploy/Caddyfile.rainyun-test
Normal file
@@ -0,0 +1,89 @@
|
||||
162-251-94-42.sslip.io {
|
||||
log {
|
||||
output file /var/log/caddy/whaletown-access.json {
|
||||
roll_size 20MiB
|
||||
roll_keep 5
|
||||
roll_keep_for 168h
|
||||
}
|
||||
format json
|
||||
}
|
||||
|
||||
encode zstd gzip
|
||||
root * /var/www/whaletown/site
|
||||
|
||||
header {
|
||||
X-Content-Type-Options nosniff
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
}
|
||||
|
||||
@play path /play/*
|
||||
header @play {
|
||||
Cross-Origin-Opener-Policy same-origin
|
||||
Cross-Origin-Embedder-Policy require-corp
|
||||
}
|
||||
|
||||
@immutable_pack {
|
||||
path /play/packs/*.pck
|
||||
file {path}
|
||||
}
|
||||
header @immutable_pack {
|
||||
Cache-Control "public, max-age=31536000, immutable"
|
||||
Content-Type "application/octet-stream"
|
||||
}
|
||||
|
||||
@pack_manifest path /play/packs/manifest.json
|
||||
header @pack_manifest Cache-Control "no-cache"
|
||||
|
||||
@runtime path /play/index.js /play/index.wasm /play/index-*.pck /play/index.audio*.js
|
||||
header @runtime Cache-Control "public, max-age=14400"
|
||||
|
||||
@html path / /index.html /play /play/ /play/index.html
|
||||
header @html Cache-Control "no-cache"
|
||||
|
||||
@play_without_slash path /play
|
||||
redir @play_without_slash /play/ 308
|
||||
|
||||
handle_path /play/* {
|
||||
root * /var/www/whaletown/site/play
|
||||
file_server
|
||||
}
|
||||
|
||||
@game path /game*
|
||||
handle @game {
|
||||
reverse_proxy http://124.221.77.216 {
|
||||
header_up Host 124.221.77.216
|
||||
}
|
||||
}
|
||||
|
||||
@location path /location-broadcast
|
||||
handle @location {
|
||||
reverse_proxy http://124.221.77.216 {
|
||||
header_up Host 124.221.77.216
|
||||
}
|
||||
}
|
||||
|
||||
@notice path /ws/notice
|
||||
handle @notice {
|
||||
reverse_proxy http://124.221.77.216 {
|
||||
header_up Host 124.221.77.216
|
||||
}
|
||||
}
|
||||
|
||||
@account_assets path /assets/account/*
|
||||
handle @account_assets {
|
||||
reverse_proxy http://124.221.77.216 {
|
||||
header_up Host 124.221.77.216
|
||||
}
|
||||
}
|
||||
|
||||
redir /api /api/
|
||||
handle /api/* {
|
||||
reverse_proxy http://124.221.77.216 {
|
||||
header_up Host 124.221.77.216
|
||||
}
|
||||
}
|
||||
|
||||
handle {
|
||||
file_server
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name whaletown.xinghangee.icu;
|
||||
server_name whaletown.novamailio.com;
|
||||
|
||||
root /var/www/whale-town-front-v2/build/web;
|
||||
index index.html;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,11 +67,20 @@ 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:
|
||||
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()
|
||||
if eventSystem == null:
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||