forked from xiangwang25/whale-town-front-v2
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 175621f66c | |||
| 366f967622 | |||
| 6fb135ba71 | |||
| 38df2541ae | |||
| 32bcaef4fa | |||
| 28c5e5ccfb | |||
| a274f6475f | |||
| 52c5a76a01 | |||
| 7a07f52e2c | |||
| bcdaa737eb | |||
| 593e1e4ea1 |
@@ -8,16 +8,16 @@
|
|||||||
"active_profile": "production",
|
"active_profile": "production",
|
||||||
"profiles": {
|
"profiles": {
|
||||||
"production": {
|
"production": {
|
||||||
"api_base_url": "https://whaletownend.xinghangee.icu",
|
"api_base_url": "https://whaletown.novamailio.com/api",
|
||||||
"chat_ws_url": "wss://whaletownend.xinghangee.icu/game",
|
"chat_ws_url": "wss://whaletown.novamailio.com/game",
|
||||||
"location_ws_url": "wss://whaletownend.xinghangee.icu/game",
|
"location_ws_url": "wss://whaletown.novamailio.com/game",
|
||||||
"game_ws_url": "wss://whaletownend.xinghangee.icu/game"
|
"game_ws_url": "wss://whaletown.novamailio.com/game"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"api_base_url": "https://whaletownend.xinghangee.icu",
|
"api_base_url": "https://whaletown.novamailio.com/api",
|
||||||
"chat_ws_url": "wss://whaletownend.xinghangee.icu/game",
|
"chat_ws_url": "wss://whaletown.novamailio.com/game",
|
||||||
"location_ws_url": "wss://whaletownend.xinghangee.icu/game",
|
"location_ws_url": "wss://whaletown.novamailio.com/game",
|
||||||
"game_ws_url": "wss://whaletownend.xinghangee.icu/game",
|
"game_ws_url": "wss://whaletown.novamailio.com/game",
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
"retry_count": 3
|
"retry_count": 3
|
||||||
},
|
},
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -16,17 +16,6 @@ WhaleTown V2 前端是基于 Godot 4.6 的 2D 多人小镇客户端。
|
|||||||
2. 用 Godot 打开 `project.godot`。
|
2. 用 Godot 打开 `project.godot`。
|
||||||
3. 运行默认主场景。
|
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 和 WebSocket 服务。本地联调时可使用以下环境变量覆盖:
|
||||||
|
|
||||||
- `WHALETOWN_API_BASE_URL`
|
- `WHALETOWN_API_BASE_URL`
|
||||||
|
|||||||
@@ -82,8 +82,6 @@ const CHAT_LOGIN_FAILED = "chat_login_failed"
|
|||||||
const CHAT_PRIVATE_TARGET_SELECTED = "chat_private_target_selected"
|
const CHAT_PRIVATE_TARGET_SELECTED = "chat_private_target_selected"
|
||||||
const CHAT_FRIEND_SELECTED = "chat_friend_selected"
|
const CHAT_FRIEND_SELECTED = "chat_friend_selected"
|
||||||
const CHAT_FRIENDS_UPDATED = "chat_friends_updated"
|
const CHAT_FRIENDS_UPDATED = "chat_friends_updated"
|
||||||
const SOCIAL_NOTIFICATION_RECEIVED = "social_notification_received"
|
|
||||||
const SOCIAL_PROFILE_UPDATED = "social_profile_updated"
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# 咖啡店陪伴机器人事件
|
# 咖啡店陪伴机器人事件
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -376,7 +376,7 @@ func has_custom_skin() -> bool:
|
|||||||
return _customSkinActive and _customSkinTexture != null
|
return _customSkinActive and _customSkinTexture != null
|
||||||
|
|
||||||
func get_custom_avatar_texture() -> Texture2D:
|
func get_custom_avatar_texture() -> Texture2D:
|
||||||
if has_custom_avatar():
|
if _customAvatarActive and _customAvatarTexture != null:
|
||||||
return _customAvatarTexture
|
return _customAvatarTexture
|
||||||
return _accountAvatarTexture
|
return _accountAvatarTexture
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,14 @@ signal email_verification_failed(message: String)
|
|||||||
signal profile_update_succeeded(profile: Dictionary)
|
signal profile_update_succeeded(profile: Dictionary)
|
||||||
signal profile_update_failed(message: String)
|
signal profile_update_failed(message: String)
|
||||||
signal logout_completed()
|
signal logout_completed()
|
||||||
|
signal browser_bootstrap_received(kind: String)
|
||||||
|
|
||||||
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
|
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
|
||||||
|
|
||||||
const DEFAULT_AUTH_CONFIG_PATH: String = "user://auth.cfg"
|
const DEFAULT_AUTH_CONFIG_PATH: String = "user://auth.cfg"
|
||||||
const REQUEST_TIMEOUT: float = 12.0
|
const REQUEST_TIMEOUT: float = 12.0
|
||||||
|
const BROWSER_BOOTSTRAP_STORAGE_KEY: String = "whaletown.auth.bootstrap"
|
||||||
|
const BROWSER_BOOTSTRAP_POLL_INTERVAL: float = 0.25
|
||||||
|
|
||||||
var _access_token: String = ""
|
var _access_token: String = ""
|
||||||
var _refresh_token: String = ""
|
var _refresh_token: String = ""
|
||||||
@@ -32,10 +35,25 @@ var _session_generation: int = 0
|
|||||||
var _account_generation: int = 0
|
var _account_generation: int = 0
|
||||||
var _refresh_in_flight: bool = false
|
var _refresh_in_flight: bool = false
|
||||||
var _auth_config_path: String = DEFAULT_AUTH_CONFIG_PATH
|
var _auth_config_path: String = DEFAULT_AUTH_CONFIG_PATH
|
||||||
var _show_welcome_after_registration: bool = false
|
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
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_load_cached_session()
|
_load_cached_session()
|
||||||
|
_try_import_browser_bootstrap(false)
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if OS.get_name() != "Web" or not _browser_bootstrap_kind.is_empty():
|
||||||
|
return
|
||||||
|
_browser_bootstrap_poll_elapsed += delta
|
||||||
|
if _browser_bootstrap_poll_elapsed < BROWSER_BOOTSTRAP_POLL_INTERVAL:
|
||||||
|
return
|
||||||
|
_browser_bootstrap_poll_elapsed = 0.0
|
||||||
|
_try_import_browser_bootstrap(true)
|
||||||
|
|
||||||
func _exit_tree() -> void:
|
func _exit_tree() -> void:
|
||||||
for request in _active_requests:
|
for request in _active_requests:
|
||||||
@@ -71,10 +89,13 @@ func get_account_generation() -> int:
|
|||||||
func get_auth_config_path() -> String:
|
func get_auth_config_path() -> String:
|
||||||
return _auth_config_path
|
return _auth_config_path
|
||||||
|
|
||||||
func consume_registration_welcome() -> bool:
|
func consume_browser_bootstrap_kind() -> String:
|
||||||
var should_show: bool = _show_welcome_after_registration
|
var kind := _browser_bootstrap_kind
|
||||||
_show_welcome_after_registration = false
|
_browser_bootstrap_kind = ""
|
||||||
return should_show
|
return kind
|
||||||
|
|
||||||
|
func get_browser_bootstrap_kind() -> String:
|
||||||
|
return _browser_bootstrap_kind
|
||||||
|
|
||||||
func login(identifier: String, password: String) -> void:
|
func login(identifier: String, password: String) -> void:
|
||||||
var normalized_identifier := identifier.strip_edges()
|
var normalized_identifier := identifier.strip_edges()
|
||||||
@@ -150,6 +171,10 @@ func register(username: String, password: String, nickname: String = "", email:
|
|||||||
if not normalized_skin_id.is_empty():
|
if not normalized_skin_id.is_empty():
|
||||||
payload["skin_id"] = normalized_skin_id
|
payload["skin_id"] = normalized_skin_id
|
||||||
|
|
||||||
|
_pending_registration_username = normalized_username
|
||||||
|
_pending_registration_password = password
|
||||||
|
_registration_recovery_in_flight = false
|
||||||
|
_registration_recovery_error = ""
|
||||||
_advance_account_generation()
|
_advance_account_generation()
|
||||||
_request_json("/auth/register", payload, _on_register_response, HTTPClient.METHOD_POST, false, true)
|
_request_json("/auth/register", payload, _on_register_response, HTTPClient.METHOD_POST, false, true)
|
||||||
|
|
||||||
@@ -295,15 +320,21 @@ func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary)
|
|||||||
|
|
||||||
func _on_register_response(success: bool, data: Dictionary, error_info: Dictionary) -> void:
|
func _on_register_response(success: bool, data: Dictionary, error_info: Dictionary) -> void:
|
||||||
if not success:
|
if not success:
|
||||||
register_failed.emit(str(error_info.get("message", "注册失败")))
|
var message := str(error_info.get("message", "注册失败"))
|
||||||
|
if _should_recover_registration(message):
|
||||||
|
_recover_registration_session(message)
|
||||||
|
return
|
||||||
|
_clear_pending_registration()
|
||||||
|
register_failed.emit(message)
|
||||||
return
|
return
|
||||||
|
|
||||||
_apply_auth_payload(data)
|
_apply_auth_payload(data)
|
||||||
if not is_authenticated():
|
if not is_authenticated():
|
||||||
|
_clear_pending_registration()
|
||||||
register_failed.emit("注册响应缺少 access_token")
|
register_failed.emit("注册响应缺少 access_token")
|
||||||
return
|
return
|
||||||
|
|
||||||
_show_welcome_after_registration = true
|
_clear_pending_registration()
|
||||||
_emit_event(EventNames.AUTH_REGISTER_SUCCESS, {
|
_emit_event(EventNames.AUTH_REGISTER_SUCCESS, {
|
||||||
"user": get_current_user()
|
"user": get_current_user()
|
||||||
})
|
})
|
||||||
@@ -311,6 +342,49 @@ func _on_register_response(success: bool, data: Dictionary, error_info: Dictiona
|
|||||||
auth_state_changed.emit(true, get_current_user())
|
auth_state_changed.emit(true, get_current_user())
|
||||||
_refresh_player_snapshot()
|
_refresh_player_snapshot()
|
||||||
|
|
||||||
|
func _should_recover_registration(message: String) -> bool:
|
||||||
|
if _registration_recovery_in_flight or _pending_registration_username.is_empty() or _pending_registration_password.is_empty():
|
||||||
|
return false
|
||||||
|
return message.begins_with("网络请求失败") \
|
||||||
|
or message.begins_with("网络请求发送失败") \
|
||||||
|
or message.contains("用户名已存在") \
|
||||||
|
or message.contains("用户名已被注册")
|
||||||
|
|
||||||
|
func _recover_registration_session(original_error: String) -> void:
|
||||||
|
_registration_recovery_in_flight = true
|
||||||
|
_registration_recovery_error = original_error
|
||||||
|
_request_json("/auth/login", {
|
||||||
|
"identifier": _pending_registration_username,
|
||||||
|
"password": _pending_registration_password,
|
||||||
|
}, _on_registration_recovery_response, HTTPClient.METHOD_POST, false, true)
|
||||||
|
|
||||||
|
func _on_registration_recovery_response(success: bool, data: Dictionary, error_info: Dictionary) -> void:
|
||||||
|
if not success:
|
||||||
|
var original_error := _registration_recovery_error
|
||||||
|
_clear_pending_registration()
|
||||||
|
register_failed.emit(str(error_info.get("message", original_error if not original_error.is_empty() else "注册状态确认失败")))
|
||||||
|
return
|
||||||
|
|
||||||
|
_apply_auth_payload(data)
|
||||||
|
if not is_authenticated():
|
||||||
|
_clear_pending_registration()
|
||||||
|
register_failed.emit("账号已创建,但登录确认失败")
|
||||||
|
return
|
||||||
|
|
||||||
|
_clear_pending_registration()
|
||||||
|
_emit_event(EventNames.AUTH_REGISTER_SUCCESS, {
|
||||||
|
"user": get_current_user()
|
||||||
|
})
|
||||||
|
register_succeeded.emit(get_current_user())
|
||||||
|
auth_state_changed.emit(true, get_current_user())
|
||||||
|
_refresh_player_snapshot()
|
||||||
|
|
||||||
|
func _clear_pending_registration() -> void:
|
||||||
|
_pending_registration_username = ""
|
||||||
|
_pending_registration_password = ""
|
||||||
|
_registration_recovery_in_flight = false
|
||||||
|
_registration_recovery_error = ""
|
||||||
|
|
||||||
func _on_refresh_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void:
|
func _on_refresh_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void:
|
||||||
_refresh_in_flight = false
|
_refresh_in_flight = false
|
||||||
if not success:
|
if not success:
|
||||||
@@ -341,6 +415,38 @@ func _apply_auth_payload(payload: Dictionary) -> void:
|
|||||||
_session_generation += 1
|
_session_generation += 1
|
||||||
_save_cached_session()
|
_save_cached_session()
|
||||||
|
|
||||||
|
func _try_import_browser_bootstrap(emit_signal: bool) -> bool:
|
||||||
|
if OS.get_name() != "Web" or not Engine.has_singleton("JavaScriptBridge"):
|
||||||
|
return false
|
||||||
|
var js_bridge: Object = Engine.get_singleton("JavaScriptBridge")
|
||||||
|
if js_bridge == null:
|
||||||
|
return false
|
||||||
|
var raw: String = str(js_bridge.eval(
|
||||||
|
"window.sessionStorage.getItem('%s') || ''" % BROWSER_BOOTSTRAP_STORAGE_KEY,
|
||||||
|
true
|
||||||
|
)).strip_edges()
|
||||||
|
if raw.is_empty():
|
||||||
|
return false
|
||||||
|
var json := JSON.new()
|
||||||
|
if json.parse(raw) != OK or not (json.data is Dictionary):
|
||||||
|
js_bridge.eval("window.sessionStorage.removeItem('%s')" % BROWSER_BOOTSTRAP_STORAGE_KEY)
|
||||||
|
return false
|
||||||
|
var bootstrap := json.data as Dictionary
|
||||||
|
var kind := str(bootstrap.get("kind", "")).strip_edges()
|
||||||
|
var payload_variant: Variant = bootstrap.get("payload", {})
|
||||||
|
if not ["login", "register"].has(kind) or not (payload_variant is Dictionary):
|
||||||
|
js_bridge.eval("window.sessionStorage.removeItem('%s')" % BROWSER_BOOTSTRAP_STORAGE_KEY)
|
||||||
|
return false
|
||||||
|
_apply_auth_payload(payload_variant as Dictionary)
|
||||||
|
if not is_authenticated():
|
||||||
|
return false
|
||||||
|
_browser_bootstrap_kind = kind
|
||||||
|
js_bridge.eval("window.sessionStorage.removeItem('%s')" % BROWSER_BOOTSTRAP_STORAGE_KEY)
|
||||||
|
if emit_signal:
|
||||||
|
browser_bootstrap_received.emit(kind)
|
||||||
|
auth_state_changed.emit(true, get_current_user())
|
||||||
|
return true
|
||||||
|
|
||||||
func _on_profile_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void:
|
func _on_profile_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void:
|
||||||
if not success:
|
if not success:
|
||||||
profile_update_failed.emit(str(_error_info.get("message", "玩家资料保存失败")))
|
profile_update_failed.emit(str(_error_info.get("message", "玩家资料保存失败")))
|
||||||
|
|||||||
@@ -979,12 +979,6 @@ func _on_data_received(message: String) -> void:
|
|||||||
_handle_chat_error(data)
|
_handle_chat_error(data)
|
||||||
"chat_render":
|
"chat_render":
|
||||||
_handle_chat_render(data)
|
_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":
|
"friend_list":
|
||||||
_handle_friend_list(data)
|
_handle_friend_list(data)
|
||||||
"friend_added":
|
"friend_added":
|
||||||
@@ -1307,41 +1301,6 @@ func _handle_chat_render(data: Dictionary) -> void:
|
|||||||
"is_private": is_private
|
"is_private": is_private
|
||||||
})
|
})
|
||||||
|
|
||||||
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})
|
|
||||||
|
|
||||||
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 ""
|
|
||||||
|
|
||||||
# 解析聊天消息时间戳(兼容 unix 秒 / ISO 8601 字符串)
|
# 解析聊天消息时间戳(兼容 unix 秒 / ISO 8601 字符串)
|
||||||
func _parse_chat_timestamp_to_unix(timestamp_raw: Variant) -> float:
|
func _parse_chat_timestamp_to_unix(timestamp_raw: Variant) -> float:
|
||||||
if typeof(timestamp_raw) == TYPE_INT or typeof(timestamp_raw) == TYPE_FLOAT:
|
if typeof(timestamp_raw) == TYPE_INT or typeof(timestamp_raw) == TYPE_FLOAT:
|
||||||
@@ -1552,14 +1511,12 @@ func _normalize_friend_requests(requests_variant: Variant) -> Array[Dictionary]:
|
|||||||
if not (request_variant is Dictionary):
|
if not (request_variant is Dictionary):
|
||||||
continue
|
continue
|
||||||
var request: Dictionary = request_variant
|
var request: Dictionary = request_variant
|
||||||
var requester_variant: Variant = request.get("requester", {})
|
var user_id := str(request.get("userId", request.get("user_id", ""))).strip_edges()
|
||||||
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()
|
|
||||||
if user_id.is_empty():
|
if user_id.is_empty():
|
||||||
continue
|
continue
|
||||||
requests.append({
|
requests.append({
|
||||||
"user_id": user_id,
|
"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()
|
"created_at": str(request.get("createdAt", request.get("created_at", ""))).strip_edges()
|
||||||
})
|
})
|
||||||
return requests
|
return requests
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -30,6 +30,8 @@ signal scene_changed(scene_name: String)
|
|||||||
# 场景切换开始信号
|
# 场景切换开始信号
|
||||||
# 参数: scene_name - 即将切换到的场景名称
|
# 参数: scene_name - 即将切换到的场景名称
|
||||||
signal scene_change_started(scene_name: String)
|
signal scene_change_started(scene_name: String)
|
||||||
|
signal scene_pack_progress(scene_name: String, downloaded_bytes: int, total_bytes: int)
|
||||||
|
signal scene_pack_failed(scene_name: String, message: String)
|
||||||
|
|
||||||
# ============ 成员变量 ============
|
# ============ 成员变量 ============
|
||||||
|
|
||||||
@@ -38,7 +40,26 @@ var current_scene_name: String = "" # 当前场景名称
|
|||||||
var is_changing_scene: bool = false # 是否正在切换场景
|
var is_changing_scene: bool = false # 是否正在切换场景
|
||||||
var _next_scene_position: Variant = null # 下一个场景的初始位置 (Vector2 or null)
|
var _next_scene_position: Variant = null # 下一个场景的初始位置 (Vector2 or null)
|
||||||
var _next_spawn_name: String = "" # 下一个场景的出生点名称 (String)
|
var _next_spawn_name: String = "" # 下一个场景的出生点名称 (String)
|
||||||
var _next_destination_id: String = "" # 快速传送目标,用于发现登记
|
var _pack_manifest: Dictionary = {}
|
||||||
|
var _loaded_scene_packs: Dictionary = {}
|
||||||
|
var _active_pack_request: HTTPRequest
|
||||||
|
var _active_pack_scene_name: String = ""
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
# 场景路径映射表
|
# 场景路径映射表
|
||||||
# 将场景名称映射到实际的文件路径
|
# 将场景名称映射到实际的文件路径
|
||||||
@@ -102,6 +123,14 @@ func change_scene(scene_name: String, use_transition: bool = true):
|
|||||||
if use_transition:
|
if use_transition:
|
||||||
await show_transition()
|
await show_transition()
|
||||||
|
|
||||||
|
if OS.get_name() == "Web" and SCENE_PACK_KEYS.has(scene_name):
|
||||||
|
var packLoaded := await _ensure_scene_pack(scene_name)
|
||||||
|
if not packLoaded:
|
||||||
|
is_changing_scene = false
|
||||||
|
if use_transition:
|
||||||
|
await hide_transition()
|
||||||
|
return false
|
||||||
|
|
||||||
# 执行场景切换
|
# 执行场景切换
|
||||||
var error = get_tree().change_scene_to_file(scene_path)
|
var error = get_tree().change_scene_to_file(scene_path)
|
||||||
if error != OK:
|
if error != OK:
|
||||||
@@ -120,6 +149,200 @@ func change_scene(scene_name: String, use_transition: bool = true):
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
if not is_instance_valid(_active_pack_request):
|
||||||
|
return
|
||||||
|
var downloaded := _active_pack_request.get_downloaded_bytes()
|
||||||
|
var total := _active_pack_request.get_body_size()
|
||||||
|
if 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 = "正在加载场景 %s" % _format_download_progress(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("正在准备场景...")
|
||||||
|
if _pack_manifest.is_empty():
|
||||||
|
_pack_manifest = await _download_pack_manifest()
|
||||||
|
if _pack_manifest.is_empty():
|
||||||
|
_show_pack_error(scene_name, "场景清单加载失败,请检查网络后重试")
|
||||||
|
return false
|
||||||
|
var entryVariant: Variant = _pack_manifest.get(packKey, {})
|
||||||
|
if not (entryVariant is Dictionary):
|
||||||
|
_show_pack_error(scene_name, "服务器缺少场景资源:%s" % packKey)
|
||||||
|
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"):
|
||||||
|
_show_pack_error(scene_name, "场景资源清单格式错误")
|
||||||
|
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):
|
||||||
|
_loaded_scene_packs[packKey] = true
|
||||||
|
_hide_pack_overlay()
|
||||||
|
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):
|
||||||
|
_show_pack_error(scene_name, "场景下载失败,请检查网络后重试")
|
||||||
|
return false
|
||||||
|
if not _is_scene_pack_file_valid(localPath, fileName, expectedSize):
|
||||||
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath))
|
||||||
|
_show_pack_error(scene_name, "场景资源下载不完整,请重新进入")
|
||||||
|
return false
|
||||||
|
if not ProjectSettings.load_resource_pack(localPath, true):
|
||||||
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath))
|
||||||
|
_show_pack_error(scene_name, "场景资源损坏,请重新进入")
|
||||||
|
return false
|
||||||
|
_loaded_scene_packs[packKey] = true
|
||||||
|
_hide_pack_overlay()
|
||||||
|
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) -> bool:
|
||||||
|
var request := HTTPRequest.new()
|
||||||
|
request.timeout = 180.0
|
||||||
|
request.download_file = local_path
|
||||||
|
add_child(request)
|
||||||
|
_active_pack_request = request
|
||||||
|
_active_pack_scene_name = scene_name
|
||||||
|
if request.request(url) != OK:
|
||||||
|
_active_pack_request = null
|
||||||
|
_active_pack_scene_name = ""
|
||||||
|
request.queue_free()
|
||||||
|
return false
|
||||||
|
var response: Array = await request.request_completed
|
||||||
|
_active_pack_request = null
|
||||||
|
_active_pack_scene_name = ""
|
||||||
|
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]
|
||||||
|
|
||||||
# ============ 查询方法 ============
|
# ============ 查询方法 ============
|
||||||
|
|
||||||
# 获取当前场景名称
|
# 获取当前场景名称
|
||||||
@@ -195,14 +418,6 @@ func get_next_spawn_name() -> String:
|
|||||||
_next_spawn_name = ""
|
_next_spawn_name = ""
|
||||||
return 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
|
|
||||||
|
|
||||||
# ============ 场景注册方法 ============
|
# ============ 场景注册方法 ============
|
||||||
|
|
||||||
# 注册新场景
|
# 注册新场景
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ const DEFAULT_SETTINGS: Dictionary = {
|
|||||||
"effects_volume": 0.90,
|
"effects_volume": 0.90,
|
||||||
"ui_scale": 1.00,
|
"ui_scale": 1.00,
|
||||||
"fullscreen": false,
|
"fullscreen": false,
|
||||||
"show_interaction_hints": true,
|
"show_interaction_hints": false,
|
||||||
"show_interaction_points": false,
|
|
||||||
"show_name_always": false,
|
"show_name_always": false,
|
||||||
"show_chat_bubbles": true,
|
"show_chat_bubbles": true,
|
||||||
"world_notifications": true,
|
"world_notifications": true,
|
||||||
@@ -24,7 +23,6 @@ const DEFAULT_SETTINGS: Dictionary = {
|
|||||||
"friend_request_notifications": true,
|
"friend_request_notifications": true,
|
||||||
"allow_nearby_private": true,
|
"allow_nearby_private": true,
|
||||||
"allow_nearby_friend_requests": true,
|
"allow_nearby_friend_requests": true,
|
||||||
"allow_nearby_profile": true,
|
|
||||||
"mute_ui_sfx": false,
|
"mute_ui_sfx": false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,438 +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 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)))
|
|
||||||
_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 _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
|
|
||||||
26
_Core/managers/UIFontManager.gd
Normal file
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
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
|
|
||||||
@@ -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_PROFILE: String = "production"
|
||||||
const DEFAULT_API_BASE_URL: String = "https://whaletownend.xinghangee.icu"
|
const DEFAULT_API_BASE_URL: String = "https://whaletown.novamailio.com/api"
|
||||||
const DEFAULT_WS_URL: String = "wss://whaletownend.xinghangee.icu/game"
|
const DEFAULT_WS_URL: String = "wss://whaletown.novamailio.com/game"
|
||||||
|
|
||||||
const API_BASE_URL_ENV_KEY: String = "WHALETOWN_API_BASE_URL"
|
const API_BASE_URL_ENV_KEY: String = "WHALETOWN_API_BASE_URL"
|
||||||
const CHAT_WS_URL_ENV_KEY: String = "WHALETOWN_CHAT_WS_URL"
|
const CHAT_WS_URL_ENV_KEY: String = "WHALETOWN_CHAT_WS_URL"
|
||||||
@@ -35,6 +35,10 @@ static func get_api_base_url() -> String:
|
|||||||
if not env_url.is_empty():
|
if not env_url.is_empty():
|
||||||
return _trim_trailing_slash(env_url)
|
return _trim_trailing_slash(env_url)
|
||||||
|
|
||||||
|
var browser_url := _get_browser_same_origin_url("/api")
|
||||||
|
if not browser_url.is_empty():
|
||||||
|
return browser_url
|
||||||
|
|
||||||
var network_config := _get_active_network_config()
|
var network_config := _get_active_network_config()
|
||||||
var url: String = str(network_config.get("api_base_url", "")).strip_edges()
|
var url: String = str(network_config.get("api_base_url", "")).strip_edges()
|
||||||
if not url.is_empty():
|
if not url.is_empty():
|
||||||
@@ -51,6 +55,10 @@ static func get_chat_ws_url() -> String:
|
|||||||
if not env_url.is_empty():
|
if not env_url.is_empty():
|
||||||
return env_url
|
return env_url
|
||||||
|
|
||||||
|
var browser_url := _get_browser_same_origin_websocket_url("/game")
|
||||||
|
if not browser_url.is_empty():
|
||||||
|
return browser_url
|
||||||
|
|
||||||
var network_config := _get_active_network_config()
|
var network_config := _get_active_network_config()
|
||||||
var url: String = str(network_config.get("chat_ws_url", network_config.get("game_ws_url", ""))).strip_edges()
|
var url: String = str(network_config.get("chat_ws_url", network_config.get("game_ws_url", ""))).strip_edges()
|
||||||
if not url.is_empty():
|
if not url.is_empty():
|
||||||
@@ -75,6 +83,10 @@ static func get_location_ws_url() -> String:
|
|||||||
if not game_env_url.is_empty():
|
if not game_env_url.is_empty():
|
||||||
return game_env_url
|
return game_env_url
|
||||||
|
|
||||||
|
var browser_url := _get_browser_same_origin_websocket_url("/game")
|
||||||
|
if not browser_url.is_empty():
|
||||||
|
return browser_url
|
||||||
|
|
||||||
var network_config := _get_active_network_config()
|
var network_config := _get_active_network_config()
|
||||||
var url: String = str(network_config.get("location_ws_url", network_config.get("game_ws_url", ""))).strip_edges()
|
var url: String = str(network_config.get("location_ws_url", network_config.get("game_ws_url", ""))).strip_edges()
|
||||||
if not url.is_empty():
|
if not url.is_empty():
|
||||||
@@ -154,7 +166,7 @@ static func _load_config() -> Dictionary:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
static func _get_browser_query_value(key: String) -> String:
|
static func _get_browser_query_value(key: String) -> String:
|
||||||
if OS.get_name() != "Web" or not OS.is_debug_build():
|
if OS.get_name() != "Web":
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
if not Engine.has_singleton("JavaScriptBridge"):
|
if not Engine.has_singleton("JavaScriptBridge"):
|
||||||
@@ -168,6 +180,25 @@ static func _get_browser_query_value(key: String) -> String:
|
|||||||
var value: Variant = js_bridge.eval(script, true)
|
var value: Variant = js_bridge.eval(script, true)
|
||||||
return str(value).strip_edges()
|
return str(value).strip_edges()
|
||||||
|
|
||||||
|
static func _get_browser_same_origin_url(path: String) -> String:
|
||||||
|
if OS.get_name() != "Web" or not Engine.has_singleton("JavaScriptBridge"):
|
||||||
|
return ""
|
||||||
|
var js_bridge: Object = Engine.get_singleton("JavaScriptBridge")
|
||||||
|
if js_bridge == null:
|
||||||
|
return ""
|
||||||
|
var origin: String = str(js_bridge.eval("window.location.origin || ''", true)).strip_edges()
|
||||||
|
if not origin.begins_with("http://") and not origin.begins_with("https://"):
|
||||||
|
return ""
|
||||||
|
return _trim_trailing_slash(origin) + path
|
||||||
|
|
||||||
|
static func _get_browser_same_origin_websocket_url(path: String) -> String:
|
||||||
|
var http_url := _get_browser_same_origin_url("")
|
||||||
|
if http_url.begins_with("https://"):
|
||||||
|
return "wss://" + http_url.substr(8) + path
|
||||||
|
if http_url.begins_with("http://"):
|
||||||
|
return "ws://" + http_url.substr(7) + path
|
||||||
|
return ""
|
||||||
|
|
||||||
static func _get_browser_url_override(key: String, allowed_schemes: Array[String]) -> String:
|
static func _get_browser_url_override(key: String, allowed_schemes: Array[String]) -> String:
|
||||||
var value := _get_browser_query_value(key)
|
var value := _get_browser_query_value(key)
|
||||||
if value.is_empty():
|
if value.is_empty():
|
||||||
|
|||||||
123
_Core/utils/WebFilePicker.gd
Normal file
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
1
_Core/utils/WebFilePicker.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dqvrn7yv0ap4y
|
||||||
BIN
assets/fonts/msyh-web.ttf
Normal file
BIN
assets/fonts/msyh-web.ttf
Normal file
Binary file not shown.
36
assets/fonts/msyh-web.ttf.import
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
BIN
assets/maps/cafe/whale_cafe_large_service_hall_base_v4.png
Normal file
Binary file not shown.
|
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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[gd_resource type="Theme" format=3 uid="uid://brk6ca2npglqc"]
|
[gd_resource type="Theme" format=3 uid="uid://brk6ca2npglqc"]
|
||||||
|
|
||||||
[ext_resource type="FontFile" uid="uid://ce7ujbeobblyr" path="res://assets/fonts/msyh.ttc" id="1_font"]
|
[ext_resource type="FontFile" uid="uid://cy7uxq32lpgou" path="res://assets/fonts/msyh-web.ttf" id="1_font"]
|
||||||
|
|
||||||
[resource]
|
[resource]
|
||||||
resource_local_to_scene = true
|
resource_local_to_scene = true
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ runnable=true
|
|||||||
advanced_options=false
|
advanced_options=false
|
||||||
dedicated_server=false
|
dedicated_server=false
|
||||||
custom_features=""
|
custom_features=""
|
||||||
export_filter="all_resources"
|
export_filter="scenes"
|
||||||
include_filter=""
|
export_files=PackedStringArray("res://scenes/ui/WebBootstrap.tscn")
|
||||||
|
include_filter="Config/*.gd,Config/*.json,_Core/*.gd,_Core/managers/*.gd,_Core/systems/*.gd,_Core/utils/*.gd,assets/audio/ui/*.wav,assets/fonts/msyh-web.ttf"
|
||||||
exclude_filter=""
|
exclude_filter=""
|
||||||
export_path="build/web/index.html"
|
export_path="build/web/index.html"
|
||||||
patches=PackedStringArray()
|
patches=PackedStringArray()
|
||||||
@@ -27,7 +28,7 @@ variant/thread_support=false
|
|||||||
vram_texture_compression/for_desktop=true
|
vram_texture_compression/for_desktop=true
|
||||||
vram_texture_compression/for_mobile=false
|
vram_texture_compression/for_mobile=false
|
||||||
html/export_icon=true
|
html/export_icon=true
|
||||||
html/custom_html_shell=""
|
html/custom_html_shell="res://web/progressive_shell.html"
|
||||||
html/head_include=""
|
html/head_include=""
|
||||||
html/canvas_resize_policy=2
|
html/canvas_resize_policy=2
|
||||||
html/focus_canvas_on_start=true
|
html/focus_canvas_on_start=true
|
||||||
@@ -72,3 +73,143 @@ texture_format/s3tc_bptc=true
|
|||||||
texture_format/etc2_astc=false
|
texture_format/etc2_astc=false
|
||||||
architecture/x86_64=true
|
architecture/x86_64=true
|
||||||
ssh_remote_deploy/enabled=false
|
ssh_remote_deploy/enabled=false
|
||||||
|
|
||||||
|
[preset.2]
|
||||||
|
|
||||||
|
name="Scene Square"
|
||||||
|
platform="Web"
|
||||||
|
runnable=false
|
||||||
|
advanced_options=false
|
||||||
|
dedicated_server=false
|
||||||
|
custom_features=""
|
||||||
|
export_filter="scenes"
|
||||||
|
export_files=PackedStringArray("res://scenes/Maps/square.tscn")
|
||||||
|
include_filter="scenes/Maps/*.gd,scenes/characters/*.gd,scenes/characters/*.tscn,scenes/prefabs/items/*.gd,scenes/prefabs/items/*.tscn,scenes/prefabs/ui/*.gd,scenes/prefabs/ui/*.tscn,scenes/ui/*.gd,scenes/ui/*.tscn,scenes/ui/mall/*.gd,scenes/ui/mall/*.tscn,assets/audio/ui/*.wav,assets/characters/*.png,assets/characters/skins/*.png,assets/ui/*.tres,assets/ui/auth/generated/*.png,assets/ui/datawhale_honor/*.png,assets/ui/mall/branding/*.png,assets/ui/mall/icons/processed/*.png,assets/ui/mall/items/*.png,assets/ui/mall/skins/*_product.png,assets/ui/settings/*.png"
|
||||||
|
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/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/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/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
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
|
|||||||
[application]
|
[application]
|
||||||
|
|
||||||
config/name="WhaleTown V2"
|
config/name="WhaleTown V2"
|
||||||
run/main_scene="res://scenes/ui/AuthScene.tscn"
|
run/main_scene="res://scenes/ui/WebBootstrap.tscn"
|
||||||
config/features=PackedStringArray("4.6", "GL Compatibility")
|
config/features=PackedStringArray("4.6", "GL Compatibility")
|
||||||
config/icon="res://icon.svg"
|
config/icon="res://icon.svg"
|
||||||
|
|
||||||
[autoload]
|
[autoload]
|
||||||
|
|
||||||
UiEscapeManager="*res://_Core/managers/UiEscapeManager.gd"
|
UIFontManager="*res://_Core/managers/UIFontManager.gd"
|
||||||
SceneManager="*res://_Core/managers/SceneManager.gd"
|
SceneManager="*res://_Core/managers/SceneManager.gd"
|
||||||
EventSystem="*res://_Core/systems/EventSystem.gd"
|
EventSystem="*res://_Core/systems/EventSystem.gd"
|
||||||
ApiClient="*res://_Core/managers/ApiClient.gd"
|
ApiClient="*res://_Core/managers/ApiClient.gd"
|
||||||
@@ -33,8 +33,6 @@ CafeCompanionManager="*res://_Core/managers/CafeCompanionManager.gd"
|
|||||||
AppearanceManager="*res://_Core/managers/AppearanceManager.gd"
|
AppearanceManager="*res://_Core/managers/AppearanceManager.gd"
|
||||||
SettingsManager="*res://_Core/managers/SettingsManager.gd"
|
SettingsManager="*res://_Core/managers/SettingsManager.gd"
|
||||||
NotificationSoundManager="*res://_Core/managers/NotificationSoundManager.gd"
|
NotificationSoundManager="*res://_Core/managers/NotificationSoundManager.gd"
|
||||||
SocialManager="*res://_Core/managers/SocialManager.gd"
|
|
||||||
InteractionManager="*res://_Core/managers/InteractionManager.gd"
|
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
@@ -43,6 +41,10 @@ window/size/viewport_height=1440
|
|||||||
window/stretch/mode="canvas_items"
|
window/stretch/mode="canvas_items"
|
||||||
window/stretch/aspect="expand"
|
window/stretch/aspect="expand"
|
||||||
|
|
||||||
|
[gui]
|
||||||
|
|
||||||
|
theme/custom_font="res://assets/fonts/msyh-web.ttf"
|
||||||
|
|
||||||
[input]
|
[input]
|
||||||
|
|
||||||
move_left={
|
move_left={
|
||||||
@@ -74,6 +76,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)
|
"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]
|
[rendering]
|
||||||
|
|
||||||
|
|||||||
@@ -45,16 +45,6 @@ func _ready() -> void:
|
|||||||
_connect_exit_area()
|
_connect_exit_area()
|
||||||
_connect_recruitment_area()
|
_connect_recruitment_area()
|
||||||
_connect_cafe_companion_events()
|
_connect_cafe_companion_events()
|
||||||
_register_interactables()
|
|
||||||
_discover_destination()
|
|
||||||
|
|
||||||
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:
|
func _exit_tree() -> void:
|
||||||
var eventSystem := get_node_or_null("/root/EventSystem")
|
var eventSystem := get_node_or_null("/root/EventSystem")
|
||||||
@@ -66,8 +56,6 @@ func _align_service_occupants() -> void:
|
|||||||
cafeWhaleBaristaNpc.global_position = serviceIdlePoint01.global_position
|
cafeWhaleBaristaNpc.global_position = serviceIdlePoint01.global_position
|
||||||
|
|
||||||
func _apply_spawn_point() -> void:
|
func _apply_spawn_point() -> void:
|
||||||
if player.has_scene_position_override:
|
|
||||||
return
|
|
||||||
var spawnName: String = SceneManager.get_next_spawn_name()
|
var spawnName: String = SceneManager.get_next_spawn_name()
|
||||||
var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn"
|
var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn"
|
||||||
var marker := $Markers.get_node_or_null(markerName) as Marker2D
|
var marker := $Markers.get_node_or_null(markerName) as Marker2D
|
||||||
@@ -87,7 +75,8 @@ func _configure_camera() -> void:
|
|||||||
playerCamera.limit_smoothed = true
|
playerCamera.limit_smoothed = true
|
||||||
|
|
||||||
func _connect_exit_area() -> void:
|
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:
|
func _connect_recruitment_area() -> void:
|
||||||
cafeRecruitmentLogoArea.input_pickable = true
|
cafeRecruitmentLogoArea.input_pickable = true
|
||||||
@@ -102,30 +91,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_AGENT_REGISTERED, _on_cafe_companion_agent_registered, self)
|
||||||
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_EMPLOYMENT_RESIGNED, _on_cafe_companion_employment_resigned, 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:
|
func _on_exit_area_body_entered(body: Node2D) -> void:
|
||||||
return
|
if _isChangingScene or body != player:
|
||||||
|
|
||||||
func _leave_to_work_zone() -> void:
|
|
||||||
if _isChangingScene:
|
|
||||||
return
|
return
|
||||||
_isChangingScene = true
|
_isChangingScene = true
|
||||||
SceneManager.set_next_scene_position(WORK_ZONE_CAFE_RETURN_POSITION)
|
SceneManager.set_next_scene_position(WORK_ZONE_CAFE_RETURN_POSITION)
|
||||||
|
|||||||
@@ -56,10 +56,19 @@ func _ready() -> void:
|
|||||||
call_deferred("_send_world_ready")
|
call_deferred("_send_world_ready")
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
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:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
return
|
if event.is_action_pressed("interact"):
|
||||||
|
if _try_start_private_chat():
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.is_action_pressed("friend_request") and _try_request_friend():
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
func _exit_tree() -> void:
|
func _exit_tree() -> void:
|
||||||
var eventSystem := _get_event_system()
|
var eventSystem := _get_event_system()
|
||||||
@@ -108,7 +117,7 @@ func _is_text_input_focused() -> bool:
|
|||||||
return false
|
return false
|
||||||
|
|
||||||
func _on_interact_pressed(_data: Dictionary = {}) -> void:
|
func _on_interact_pressed(_data: Dictionary = {}) -> void:
|
||||||
return
|
_try_start_private_chat()
|
||||||
|
|
||||||
func _try_start_private_chat() -> bool:
|
func _try_start_private_chat() -> bool:
|
||||||
if _is_text_input_focused():
|
if _is_text_input_focused():
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ var _inventoryRequestInFlight: bool = false
|
|||||||
var _inventoryRequestReportsErrors: bool = false
|
var _inventoryRequestReportsErrors: bool = false
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
add_to_group("whaletown_escape_dismissible")
|
|
||||||
_leave_multiplayer_world()
|
_leave_multiplayer_world()
|
||||||
_apply_spawn_point()
|
_apply_spawn_point()
|
||||||
_configure_camera()
|
_configure_camera()
|
||||||
@@ -55,17 +54,8 @@ func _ready() -> void:
|
|||||||
_build_room_decor_ui()
|
_build_room_decor_ui()
|
||||||
_connect_room_decor_events()
|
_connect_room_decor_events()
|
||||||
_fetch_room_decor_inventory()
|
_fetch_room_decor_inventory()
|
||||||
_discover_destination()
|
|
||||||
set_process(false)
|
set_process(false)
|
||||||
|
|
||||||
func _discover_destination() -> void:
|
|
||||||
var destination_id := SceneManager.get_next_destination_id()
|
|
||||||
if destination_id.is_empty():
|
|
||||||
destination_id = "personal_room"
|
|
||||||
var socialManager := get_node_or_null("/root/SocialManager")
|
|
||||||
if socialManager != null and socialManager.has_method("discover_destination"):
|
|
||||||
socialManager.call("discover_destination", destination_id)
|
|
||||||
|
|
||||||
func _leave_multiplayer_world() -> void:
|
func _leave_multiplayer_world() -> void:
|
||||||
var chatManager := get_node_or_null("/root/ChatManager")
|
var chatManager := get_node_or_null("/root/ChatManager")
|
||||||
if chatManager != null and chatManager.has_method("leave_world"):
|
if chatManager != null and chatManager.has_method("leave_world"):
|
||||||
@@ -83,8 +73,6 @@ func _exit_tree() -> void:
|
|||||||
saveManager.decor_save_failed.disconnect(_on_decor_save_failed)
|
saveManager.decor_save_failed.disconnect(_on_decor_save_failed)
|
||||||
|
|
||||||
func _input(event: InputEvent) -> void:
|
func _input(event: InputEvent) -> void:
|
||||||
if get_viewport().is_input_handled():
|
|
||||||
return
|
|
||||||
if event is InputEventMouseButton:
|
if event is InputEventMouseButton:
|
||||||
var mouseEvent := event as InputEventMouseButton
|
var mouseEvent := event as InputEventMouseButton
|
||||||
if mouseEvent.button_index != MOUSE_BUTTON_LEFT:
|
if mouseEvent.button_index != MOUSE_BUTTON_LEFT:
|
||||||
@@ -103,22 +91,7 @@ func _process(_delta: float) -> void:
|
|||||||
if _draggedDecor != null:
|
if _draggedDecor != null:
|
||||||
_update_dragged_decor_position()
|
_update_dragged_decor_position()
|
||||||
|
|
||||||
func is_escape_dismissible() -> bool:
|
|
||||||
return _draggedDecor != null or (is_instance_valid(_inventoryPanel) and _inventoryPanel.visible)
|
|
||||||
|
|
||||||
func get_escape_priority() -> int:
|
|
||||||
return 500
|
|
||||||
|
|
||||||
func request_escape_close() -> void:
|
|
||||||
if _draggedDecor != null:
|
|
||||||
_finish_decor_drag()
|
|
||||||
return
|
|
||||||
if is_instance_valid(_inventoryPanel):
|
|
||||||
_inventoryPanel.visible = false
|
|
||||||
|
|
||||||
func _apply_spawn_point() -> void:
|
func _apply_spawn_point() -> void:
|
||||||
if player.has_scene_position_override:
|
|
||||||
return
|
|
||||||
var spawnName: String = SceneManager.get_next_spawn_name()
|
var spawnName: String = SceneManager.get_next_spawn_name()
|
||||||
var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn"
|
var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn"
|
||||||
var marker := $Markers.get_node_or_null(markerName) as Marker2D
|
var marker := $Markers.get_node_or_null(markerName) as Marker2D
|
||||||
|
|||||||
@@ -10,23 +10,14 @@ extends Area2D
|
|||||||
@export var targetSceneName: String = ""
|
@export var targetSceneName: String = ""
|
||||||
@export var targetSpawnName: String = ""
|
@export var targetSpawnName: String = ""
|
||||||
@export var targetPosition: Vector2 = Vector2.ZERO
|
@export var targetPosition: Vector2 = Vector2.ZERO
|
||||||
@export var interactionTitle: String = "进入"
|
|
||||||
@export var interactionDistance: float = 160.0
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
var label: String = interactionTitle.strip_edges()
|
body_entered.connect(_on_body_entered)
|
||||||
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)
|
|
||||||
|
|
||||||
func _on_body_entered(body: Node2D) -> void:
|
func _on_body_entered(body: Node2D) -> void:
|
||||||
return
|
if not (body is PlayerController):
|
||||||
|
return
|
||||||
|
_change_scene()
|
||||||
|
|
||||||
func _change_scene() -> void:
|
func _change_scene() -> void:
|
||||||
if targetSceneName.is_empty():
|
if targetSceneName.is_empty():
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ const CAMERA_LIMIT_LEFT: int = -1280
|
|||||||
const CAMERA_LIMIT_TOP: int = -960
|
const CAMERA_LIMIT_TOP: int = -960
|
||||||
const CAMERA_LIMIT_RIGHT: int = 1280
|
const CAMERA_LIMIT_RIGHT: int = 1280
|
||||||
const CAMERA_LIMIT_BOTTOM: int = 960
|
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 player: PlayerController = $YSortWorld/Characters/Players/Player
|
||||||
@onready var playerCamera: Camera2D = $YSortWorld/Characters/Players/Player/Camera2D
|
@onready var playerCamera: Camera2D = $YSortWorld/Characters/Players/Player/Camera2D
|
||||||
@@ -22,35 +20,8 @@ const WELCOME_DIALOG_NAME: String = "WelcomeDialog"
|
|||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_apply_spawn_point()
|
_apply_spawn_point()
|
||||||
_configure_camera()
|
_configure_camera()
|
||||||
_discover_destination()
|
|
||||||
call_deferred("_show_registration_welcome")
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
func _apply_spawn_point() -> void:
|
func _apply_spawn_point() -> void:
|
||||||
if player.has_scene_position_override:
|
|
||||||
return
|
|
||||||
var spawnName: String = SceneManager.get_next_spawn_name()
|
var spawnName: String = SceneManager.get_next_spawn_name()
|
||||||
var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn"
|
var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn"
|
||||||
var marker := $Markers.get_node_or_null(markerName) as Marker2D
|
var marker := $Markers.get_node_or_null(markerName) as Marker2D
|
||||||
|
|||||||
@@ -38,23 +38,12 @@ func _ready() -> void:
|
|||||||
_ensure_mall_entrance_area()
|
_ensure_mall_entrance_area()
|
||||||
EventSystem.connect_event(EventNames.OBJECT_INTERACTED, _on_object_interacted, self)
|
EventSystem.connect_event(EventNames.OBJECT_INTERACTED, _on_object_interacted, self)
|
||||||
EventSystem.connect_event(EventNames.MALL_CLOSED, _on_mall_closed, self)
|
EventSystem.connect_event(EventNames.MALL_CLOSED, _on_mall_closed, self)
|
||||||
_discover_destination()
|
|
||||||
|
|
||||||
func _discover_destination() -> void:
|
|
||||||
var destination_id := SceneManager.get_next_destination_id()
|
|
||||||
if destination_id.is_empty():
|
|
||||||
destination_id = "work_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:
|
func _exit_tree() -> void:
|
||||||
EventSystem.disconnect_event(EventNames.OBJECT_INTERACTED, _on_object_interacted, self)
|
EventSystem.disconnect_event(EventNames.OBJECT_INTERACTED, _on_object_interacted, self)
|
||||||
EventSystem.disconnect_event(EventNames.MALL_CLOSED, _on_mall_closed, self)
|
EventSystem.disconnect_event(EventNames.MALL_CLOSED, _on_mall_closed, self)
|
||||||
|
|
||||||
func _apply_spawn_point() -> void:
|
func _apply_spawn_point() -> void:
|
||||||
if player.has_scene_position_override:
|
|
||||||
return
|
|
||||||
var spawnName: String = SceneManager.get_next_spawn_name()
|
var spawnName: String = SceneManager.get_next_spawn_name()
|
||||||
var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn"
|
var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn"
|
||||||
var marker := $Markers.get_node_or_null(markerName) as Marker2D
|
var marker := $Markers.get_node_or_null(markerName) as Marker2D
|
||||||
@@ -123,14 +112,15 @@ func _ensure_mall_entrance_area() -> void:
|
|||||||
|
|
||||||
func _setup_mall_entrance_trigger(entrance: Area2D) -> void:
|
func _setup_mall_entrance_trigger(entrance: Area2D) -> void:
|
||||||
_mallEntranceArea = entrance
|
_mallEntranceArea = entrance
|
||||||
_mallEntranceArea.collision_mask = 0
|
_mallEntranceArea.collision_mask = PLAYER_COLLISION_LAYER
|
||||||
|
if not _mallEntranceArea.body_entered.is_connected(_on_mall_entrance_body_entered):
|
||||||
|
_mallEntranceArea.body_entered.connect(_on_mall_entrance_body_entered)
|
||||||
|
|
||||||
func _on_object_interacted(data: Dictionary) -> void:
|
func _on_object_interacted(data: Dictionary) -> void:
|
||||||
var buildingId := str(data.get("buildingId", ""))
|
var buildingId := str(data.get("buildingId", ""))
|
||||||
if buildingId.is_empty():
|
if buildingId.is_empty():
|
||||||
return
|
return
|
||||||
if buildingId == "whale_super_mall":
|
if buildingId == "whale_super_mall":
|
||||||
_enter_mall()
|
|
||||||
return
|
return
|
||||||
if buildingId == "virtual_whale_recruitment_board":
|
if buildingId == "virtual_whale_recruitment_board":
|
||||||
_open_course_board()
|
_open_course_board()
|
||||||
@@ -152,7 +142,9 @@ func _open_course_board() -> void:
|
|||||||
_courseBoardPanel.call("show_panel")
|
_courseBoardPanel.call("show_panel")
|
||||||
|
|
||||||
func _on_mall_entrance_body_entered(body: Node2D) -> void:
|
func _on_mall_entrance_body_entered(body: Node2D) -> void:
|
||||||
return
|
if body != player or not _mallCanEnter:
|
||||||
|
return
|
||||||
|
_enter_mall()
|
||||||
|
|
||||||
func _enter_mall() -> void:
|
func _enter_mall() -> void:
|
||||||
if _isInsideMall:
|
if _isInsideMall:
|
||||||
|
|||||||
@@ -12,23 +12,10 @@ const INTERACTION_COLLISION_LAYER: int = 2
|
|||||||
@export var buildingId: String = ""
|
@export var buildingId: String = ""
|
||||||
@export var buildingTitle: String = ""
|
@export var buildingTitle: String = ""
|
||||||
@export var buildingRole: String = ""
|
@export var buildingRole: String = ""
|
||||||
@export var interactionDistance: float = 150.0
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
collision_layer = INTERACTION_COLLISION_LAYER
|
collision_layer = INTERACTION_COLLISION_LAYER
|
||||||
collision_mask = 0
|
collision_mask = 0
|
||||||
var interactable: InteractableComponent = InteractableComponent.new()
|
|
||||||
interactable.interaction_distance = interactionDistance
|
|
||||||
add_child(interactable)
|
|
||||||
|
|
||||||
func is_interaction_active(_component: InteractableComponent) -> bool:
|
|
||||||
return not buildingId.strip_edges().is_empty()
|
|
||||||
|
|
||||||
func build_interaction_actions(_component: InteractableComponent, _player: Node2D) -> Array[InteractionAction]:
|
|
||||||
var actions: Array[InteractionAction] = []
|
|
||||||
var title: String = buildingTitle if not buildingTitle.is_empty() else "设施"
|
|
||||||
actions.append(InteractionAction.create("building:%s" % buildingId, "使用 %s" % title, 30, Callable(self, "interact")))
|
|
||||||
return actions
|
|
||||||
|
|
||||||
func interact() -> void:
|
func interact() -> void:
|
||||||
var payload := {
|
var payload := {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[gd_scene format=3 uid="uid://c2flhkqk5icab"]
|
[gd_scene format=3 uid="uid://c2flhkqk5icab"]
|
||||||
|
|
||||||
[ext_resource type="Script" uid="uid://ccesagjisqodl" path="res://scenes/Maps/CafeInterior.gd" id="1_script"]
|
[ext_resource type="Script" uid="uid://ccesagjisqodl" path="res://scenes/Maps/CafeInterior.gd" id="1_script"]
|
||||||
[ext_resource type="Texture2D" path="res://assets/maps/cafe/cafe_service_hall_base_v1.png" id="2_map"]
|
[ext_resource type="Texture2D" path="res://assets/maps/cafe/whale_cafe_large_service_hall_base_v4.png" id="2_map"]
|
||||||
[ext_resource type="PackedScene" path="res://scenes/characters/player.tscn" id="3_player"]
|
[ext_resource type="PackedScene" path="res://scenes/characters/player.tscn" id="3_player"]
|
||||||
[ext_resource type="PackedScene" path="res://scenes/ui/ChatUI.tscn" id="4_chatui"]
|
[ext_resource type="PackedScene" path="res://scenes/ui/ChatUI.tscn" id="4_chatui"]
|
||||||
[ext_resource type="PackedScene" path="res://scenes/ui/PlayerHud.tscn" id="5_playerhud"]
|
[ext_resource type="PackedScene" path="res://scenes/ui/PlayerHud.tscn" id="5_playerhud"]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
[gd_scene load_steps=13 format=4]
|
[gd_scene load_steps=12 format=4]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://scenes/Maps/PersonalSpace.gd" id="1_personal_space"]
|
[ext_resource type="Script" path="res://scenes/Maps/PersonalSpace.gd" id="1_personal_space"]
|
||||||
[ext_resource type="Texture2D" path="res://assets/maps/personal_space/v1/base/personal_room_25d_sidewalls_wider_not_longer_v1.png" id="2_room_base"]
|
[ext_resource type="Texture2D" path="res://assets/maps/personal_space/v1/base/personal_room_25d_sidewalls_wider_not_longer_v1.png" id="2_room_base"]
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
[ext_resource type="PackedScene" path="res://scenes/ui/PlayerHud.tscn" id="6_playerhud"]
|
[ext_resource type="PackedScene" path="res://scenes/ui/PlayerHud.tscn" id="6_playerhud"]
|
||||||
[ext_resource type="PackedScene" path="res://scenes/ui/FriendListPanel.tscn" id="7_friendpanel"]
|
[ext_resource type="PackedScene" path="res://scenes/ui/FriendListPanel.tscn" id="7_friendpanel"]
|
||||||
[ext_resource type="PackedScene" path="res://scenes/ui/SettingsPanel.tscn" id="8_settingspanel"]
|
[ext_resource type="PackedScene" path="res://scenes/ui/SettingsPanel.tscn" id="8_settingspanel"]
|
||||||
[ext_resource type="PackedScene" path="res://scenes/ui/MapPanel.tscn" id="9_mappanel"]
|
|
||||||
|
|
||||||
[sub_resource type="RectangleShape2D" id="Shape_TopWall"]
|
[sub_resource type="RectangleShape2D" id="Shape_TopWall"]
|
||||||
size = Vector2(882, 76)
|
size = Vector2(882, 76)
|
||||||
@@ -36,8 +35,6 @@ layer = 10
|
|||||||
|
|
||||||
[node name="SettingsPanel" parent="UILayer" instance=ExtResource("8_settingspanel")]
|
[node name="SettingsPanel" parent="UILayer" instance=ExtResource("8_settingspanel")]
|
||||||
|
|
||||||
[node name="MapPanel" parent="UILayer" instance=ExtResource("9_mappanel")]
|
|
||||||
|
|
||||||
[node name="StageBackdrop" type="ColorRect" parent="."]
|
[node name="StageBackdrop" type="ColorRect" parent="."]
|
||||||
z_index = -200
|
z_index = -200
|
||||||
offset_left = -2000.0
|
offset_left = -2000.0
|
||||||
|
|||||||
@@ -117,6 +117,9 @@ size = Vector2(180, 160)
|
|||||||
[sub_resource type="RectangleShape2D" id="Shape_NoticeBoard"]
|
[sub_resource type="RectangleShape2D" id="Shape_NoticeBoard"]
|
||||||
size = Vector2(123, 88)
|
size = Vector2(123, 88)
|
||||||
|
|
||||||
|
[sub_resource type="RectangleShape2D" id="Shape_WelcomeBoard"]
|
||||||
|
size = Vector2(97.5, 95)
|
||||||
|
|
||||||
[node name="Square" type="Node2D" unique_id=1071968945]
|
[node name="Square" type="Node2D" unique_id=1071968945]
|
||||||
script = ExtResource("43_square")
|
script = ExtResource("43_square")
|
||||||
|
|
||||||
@@ -1234,6 +1237,15 @@ script = ExtResource("28_notice")
|
|||||||
position = Vector2(-59.5, 19)
|
position = Vector2(-59.5, 19)
|
||||||
shape = SubResource("Shape_NoticeBoard")
|
shape = SubResource("Shape_NoticeBoard")
|
||||||
|
|
||||||
|
[node name="WelcomeBoardArea" type="Area2D" parent="InteractionAreas" unique_id=1460340742]
|
||||||
|
position = Vector2(707.5, 575)
|
||||||
|
collision_layer = 0
|
||||||
|
collision_mask = 0
|
||||||
|
|
||||||
|
[node name="CollisionShape2D" type="CollisionShape2D" parent="InteractionAreas/WelcomeBoardArea" unique_id=123982451]
|
||||||
|
position = Vector2(-159.25, -10.5)
|
||||||
|
shape = SubResource("Shape_WelcomeBoard")
|
||||||
|
|
||||||
[node name="BlockoutDebug" type="Node2D" parent="." unique_id=932099081]
|
[node name="BlockoutDebug" type="Node2D" parent="." unique_id=932099081]
|
||||||
visible = false
|
visible = false
|
||||||
|
|
||||||
|
|||||||
@@ -19,22 +19,9 @@ class_name CafeCompanionTarget
|
|||||||
var _lastClickMsec: int = 0
|
var _lastClickMsec: int = 0
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
var interactable: InteractableComponent = InteractableComponent.new()
|
|
||||||
interactable.interaction_distance = 160.0
|
|
||||||
add_child(interactable)
|
|
||||||
input_pickable = true
|
input_pickable = true
|
||||||
set_process_unhandled_input(true)
|
set_process_unhandled_input(true)
|
||||||
|
|
||||||
func is_interaction_active(_component: InteractableComponent) -> bool:
|
|
||||||
return not servicePointId.strip_edges().is_empty() and not companionId.strip_edges().is_empty()
|
|
||||||
|
|
||||||
func build_interaction_actions(_component: InteractableComponent, _player: Node2D) -> Array[InteractionAction]:
|
|
||||||
var actions: Array[InteractionAction] = []
|
|
||||||
var display_name: String = _resolved_persona_name()
|
|
||||||
var action_id: String = "cafe_companion:%s" % servicePointId
|
|
||||||
actions.append(InteractionAction.create(action_id, "与 %s 交流" % display_name, 45, Callable(self, "_try_emit_target_selected")))
|
|
||||||
return actions
|
|
||||||
|
|
||||||
func _input_event(_viewport: Viewport, event: InputEvent, _shapeIdx: int) -> void:
|
func _input_event(_viewport: Viewport, event: InputEvent, _shapeIdx: int) -> void:
|
||||||
if not (event is InputEventMouseButton):
|
if not (event is InputEventMouseButton):
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -34,21 +34,12 @@ const NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12
|
|||||||
@export_multiline var dialogue: String = "欢迎来到WhaleTown,我是镇长范鲸晶"
|
@export_multiline var dialogue: String = "欢迎来到WhaleTown,我是镇长范鲸晶"
|
||||||
@export var showNameplate: bool = false
|
@export var showNameplate: bool = false
|
||||||
@export var nameplateOffsetY: float = -112.0
|
@export var nameplateOffsetY: float = -112.0
|
||||||
@export var interactionDistance: float = 150.0
|
|
||||||
|
|
||||||
@onready var animation_player: AnimationPlayer = $AnimationPlayer
|
@onready var animation_player: AnimationPlayer = $AnimationPlayer
|
||||||
|
|
||||||
var _nameplate: Label
|
var _nameplate: Label
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
if get_node_or_null("CafeCompanionTarget") == null:
|
|
||||||
var interactable: InteractableComponent = InteractableComponent.new()
|
|
||||||
interactable.interaction_id = "npc:%s" % str(get_path())
|
|
||||||
interactable.interaction_title = "与 %s 交谈" % npcName
|
|
||||||
interactable.interaction_priority = 40
|
|
||||||
interactable.interaction_distance = interactionDistance
|
|
||||||
interactable.activation_method = &"interact"
|
|
||||||
add_child(interactable)
|
|
||||||
# 播放场景里配置好的待机动画,让不同 NPC 可以复用同一个控制器。
|
# 播放场景里配置好的待机动画,让不同 NPC 可以复用同一个控制器。
|
||||||
if animation_player.has_animation("idle"):
|
if animation_player.has_animation("idle"):
|
||||||
animation_player.play("idle")
|
animation_player.play("idle")
|
||||||
@@ -72,7 +63,7 @@ func interact() -> void:
|
|||||||
"npc_name": npcName,
|
"npc_name": npcName,
|
||||||
"dialogue": dialogue
|
"dialogue": dialogue
|
||||||
})
|
})
|
||||||
interaction_happened.emit(dialogue)
|
interaction_happened.emit(dialogue)
|
||||||
|
|
||||||
# 在 NPC 头顶生成一次性聊天气泡。
|
# 在 NPC 头顶生成一次性聊天气泡。
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -25,11 +25,8 @@ const DIRECTION_ROWS: Dictionary = {
|
|||||||
var lastDirection: String = "down"
|
var lastDirection: String = "down"
|
||||||
var _nameLabel: Label
|
var _nameLabel: Label
|
||||||
var _movementLocked: bool = false
|
var _movementLocked: bool = false
|
||||||
var has_scene_position_override: bool = false
|
|
||||||
var _discoveryElapsed: float = 0.0
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
add_to_group("whaletown_local_player")
|
|
||||||
_reset_movement_input_state()
|
_reset_movement_input_state()
|
||||||
_apply_current_appearance()
|
_apply_current_appearance()
|
||||||
_subscribe_to_appearance_events()
|
_subscribe_to_appearance_events()
|
||||||
@@ -74,40 +71,28 @@ func _release_movement_actions() -> void:
|
|||||||
|
|
||||||
func _check_spawn_position() -> void:
|
func _check_spawn_position() -> void:
|
||||||
var spawnPos: Variant = SceneManager.get_next_scene_position()
|
var spawnPos: Variant = SceneManager.get_next_scene_position()
|
||||||
if spawnPos is Vector2:
|
if spawnPos != null:
|
||||||
global_position = spawnPos
|
global_position = spawnPos
|
||||||
has_scene_position_override = true
|
_update_world_sort_z()
|
||||||
_update_world_sort_z()
|
|
||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
_handle_movement(delta)
|
_handle_movement(delta)
|
||||||
_update_world_sort_z()
|
_update_world_sort_z()
|
||||||
_handle_interaction()
|
_handle_interaction()
|
||||||
_report_nearby_discoveries(delta)
|
|
||||||
|
|
||||||
func _handle_interaction() -> void:
|
func _handle_interaction() -> void:
|
||||||
# 统一交互由 /root/InteractionManager 收集附近候选并处理 E 键。
|
if _is_text_input_focused():
|
||||||
return
|
|
||||||
|
|
||||||
func _report_nearby_discoveries(delta: float) -> void:
|
|
||||||
_discoveryElapsed += delta
|
|
||||||
if _discoveryElapsed < 0.35:
|
|
||||||
return
|
return
|
||||||
_discoveryElapsed = 0.0
|
if Input.is_action_just_pressed("interact"):
|
||||||
var scene := get_tree().current_scene
|
EventSystem.emit_event(EventNames.INTERACT_PRESSED, {
|
||||||
if scene == null:
|
"player": self,
|
||||||
return
|
"position": global_position,
|
||||||
var map_id: String = str({
|
"direction": lastDirection
|
||||||
"Square": "whale_port",
|
})
|
||||||
"WorkZone": "work_zone",
|
if ray_cast.is_colliding():
|
||||||
"CafeInterior": "whale_cafe",
|
var collider := ray_cast.get_collider()
|
||||||
"PersonalSpace": "personal_space",
|
if collider and collider.has_method("interact"):
|
||||||
}.get(str(scene.name), ""))
|
collider.interact()
|
||||||
if map_id.is_empty():
|
|
||||||
return
|
|
||||||
var social_manager := get_node_or_null("/root/SocialManager")
|
|
||||||
if social_manager != null and social_manager.has_method("discover_nearby_destinations"):
|
|
||||||
social_manager.call("discover_nearby_destinations", map_id, global_position)
|
|
||||||
|
|
||||||
func _handle_movement(_delta: float) -> void:
|
func _handle_movement(_delta: float) -> void:
|
||||||
if _movementLocked:
|
if _movementLocked:
|
||||||
@@ -126,7 +111,10 @@ func _handle_movement(_delta: float) -> void:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# 获取移动向量 (参考 docs/02-开发规范/输入映射配置.md)
|
# 获取移动向量 (参考 docs/02-开发规范/输入映射配置.md)
|
||||||
var direction := _movement_direction()
|
var direction := Input.get_vector(
|
||||||
|
"move_left", "move_right",
|
||||||
|
"move_up", "move_down"
|
||||||
|
)
|
||||||
|
|
||||||
# 应用移动
|
# 应用移动
|
||||||
if direction != Vector2.ZERO:
|
if direction != Vector2.ZERO:
|
||||||
@@ -145,16 +133,6 @@ func _handle_movement(_delta: float) -> void:
|
|||||||
"position": global_position
|
"position": global_position
|
||||||
})
|
})
|
||||||
|
|
||||||
func _movement_direction() -> Vector2:
|
|
||||||
var interactionManager := get_node_or_null("/root/InteractionManager")
|
|
||||||
if interactionManager != null and interactionManager.has_method("is_selection_active") and bool(interactionManager.call("is_selection_active")):
|
|
||||||
# 有交互候选时方向键用于选择,保留 WASD 移动。
|
|
||||||
return Vector2(
|
|
||||||
(-1.0 if Input.is_key_pressed(KEY_A) else 0.0) + (1.0 if Input.is_key_pressed(KEY_D) else 0.0),
|
|
||||||
(-1.0 if Input.is_key_pressed(KEY_W) else 0.0) + (1.0 if Input.is_key_pressed(KEY_S) else 0.0)
|
|
||||||
).normalized()
|
|
||||||
return Input.get_vector("move_left", "move_right", "move_up", "move_down")
|
|
||||||
|
|
||||||
func _update_animation_state(direction: Vector2) -> void:
|
func _update_animation_state(direction: Vector2) -> void:
|
||||||
if not animation_player:
|
if not animation_player:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -36,12 +36,8 @@ const DIRECTION_ROWS: Dictionary = {
|
|||||||
@onready var sprite: Sprite2D = $Sprite2D
|
@onready var sprite: Sprite2D = $Sprite2D
|
||||||
var _nameLabel: Label
|
var _nameLabel: Label
|
||||||
var _cafeCompanionTarget: CafeCompanionTarget
|
var _cafeCompanionTarget: CafeCompanionTarget
|
||||||
var _interactable: InteractableComponent
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_interactable = InteractableComponent.new()
|
|
||||||
_interactable.interaction_distance = 160.0
|
|
||||||
add_child(_interactable)
|
|
||||||
# 初始化时确保无物理处理
|
# 初始化时确保无物理处理
|
||||||
set_physics_process(false)
|
set_physics_process(false)
|
||||||
# 初始位置设为当前位置
|
# 初始位置设为当前位置
|
||||||
@@ -56,32 +52,6 @@ func _ready() -> void:
|
|||||||
if has_node("CollisionShape2D"):
|
if has_node("CollisionShape2D"):
|
||||||
$CollisionShape2D.disabled = true
|
$CollisionShape2D.disabled = true
|
||||||
|
|
||||||
func is_interaction_active(_component: InteractableComponent) -> bool:
|
|
||||||
return not userId.strip_edges().is_empty()
|
|
||||||
|
|
||||||
func build_interaction_actions(_component: InteractableComponent, _player: Node2D) -> Array[InteractionAction]:
|
|
||||||
var actions: Array[InteractionAction] = []
|
|
||||||
var display_name := username if not username.strip_edges().is_empty() else "玩家"
|
|
||||||
actions.append(InteractionAction.create("player_card:%s" % userId, "查看 %s 的社区名片" % display_name, 60, Callable(self, "_show_community_profile")))
|
|
||||||
actions.append(InteractionAction.create("player_dm:%s" % userId, "私聊 %s" % display_name, 61, Callable(self, "_open_private_chat")))
|
|
||||||
actions.append(InteractionAction.create("player_friend:%s" % userId, "申请添加 %s 为好友" % display_name, 62, Callable(self, "_request_friend")))
|
|
||||||
return actions
|
|
||||||
|
|
||||||
func _show_community_profile() -> void:
|
|
||||||
var socialManager := get_node_or_null("/root/SocialManager")
|
|
||||||
if socialManager != null and socialManager.has_method("show_profile"):
|
|
||||||
socialManager.call("show_profile", userId)
|
|
||||||
|
|
||||||
func _open_private_chat() -> void:
|
|
||||||
var socialManager := get_node_or_null("/root/SocialManager")
|
|
||||||
if socialManager != null and socialManager.has_method("open_private_chat"):
|
|
||||||
socialManager.call("open_private_chat", userId, username)
|
|
||||||
|
|
||||||
func _request_friend() -> void:
|
|
||||||
var socialManager := get_node_or_null("/root/SocialManager")
|
|
||||||
if socialManager != null and socialManager.has_method("request_friend"):
|
|
||||||
socialManager.call("request_friend", userId, username)
|
|
||||||
|
|
||||||
func _exit_tree() -> void:
|
func _exit_tree() -> void:
|
||||||
var eventSystem := get_node_or_null("/root/EventSystem")
|
var eventSystem := get_node_or_null("/root/EventSystem")
|
||||||
if eventSystem != null:
|
if eventSystem != null:
|
||||||
|
|||||||
@@ -8,13 +8,6 @@ const INTERACTION_COLLISION_LAYER: int = 2
|
|||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
collision_layer = INTERACTION_COLLISION_LAYER
|
collision_layer = INTERACTION_COLLISION_LAYER
|
||||||
collision_mask = 0
|
collision_mask = 0
|
||||||
var interactable: InteractableComponent = InteractableComponent.new()
|
|
||||||
interactable.interaction_id = "honor_board:%s" % str(get_path())
|
|
||||||
interactable.interaction_title = "查看荣誉榜"
|
|
||||||
interactable.interaction_priority = 35
|
|
||||||
interactable.interaction_distance = 150.0
|
|
||||||
interactable.activation_method = &"interact"
|
|
||||||
add_child(interactable)
|
|
||||||
|
|
||||||
func interact() -> void:
|
func interact() -> void:
|
||||||
var root: Window = get_tree().root
|
var root: Window = get_tree().root
|
||||||
|
|||||||
@@ -8,13 +8,6 @@ const INTERACTION_COLLISION_LAYER: int = 2
|
|||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
collision_layer = INTERACTION_COLLISION_LAYER
|
collision_layer = INTERACTION_COLLISION_LAYER
|
||||||
collision_mask = 0
|
collision_mask = 0
|
||||||
var interactable: InteractableComponent = InteractableComponent.new()
|
|
||||||
interactable.interaction_id = "notice_board:%s" % str(get_path())
|
|
||||||
interactable.interaction_title = "查看公告栏"
|
|
||||||
interactable.interaction_priority = 35
|
|
||||||
interactable.interaction_distance = 150.0
|
|
||||||
interactable.activation_method = &"interact"
|
|
||||||
add_child(interactable)
|
|
||||||
|
|
||||||
func interact() -> void:
|
func interact() -> void:
|
||||||
var root: Window = get_tree().root
|
var root: Window = get_tree().root
|
||||||
|
|||||||
@@ -8,13 +8,6 @@ const INTERACTION_COLLISION_LAYER: int = 2
|
|||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
collision_layer = INTERACTION_COLLISION_LAYER
|
collision_layer = INTERACTION_COLLISION_LAYER
|
||||||
collision_mask = 0
|
collision_mask = 0
|
||||||
var interactable: InteractableComponent = InteractableComponent.new()
|
|
||||||
interactable.interaction_id = "welcome_board:%s" % str(get_path())
|
|
||||||
interactable.interaction_title = "查看新人引导"
|
|
||||||
interactable.interaction_priority = 35
|
|
||||||
interactable.interaction_distance = 150.0
|
|
||||||
interactable.activation_method = &"interact"
|
|
||||||
add_child(interactable)
|
|
||||||
|
|
||||||
func interact() -> void:
|
func interact() -> void:
|
||||||
var root: Window = get_tree().root
|
var root: Window = get_tree().root
|
||||||
|
|||||||
@@ -24,14 +24,16 @@ const REGISTRATION_CHOICE_UPLOAD_FILE_BOX_PATH: String = REGISTRATION_CHOICE_ASS
|
|||||||
const REGISTRATION_CHOICE_SPRITESHEET_GRID_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/spritesheet_grid_box.png"
|
const REGISTRATION_CHOICE_SPRITESHEET_GRID_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/spritesheet_grid_box.png"
|
||||||
const REGISTRATION_CHOICE_REFERENCE_UPLOAD_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/reference_upload_box.png"
|
const REGISTRATION_CHOICE_REFERENCE_UPLOAD_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/reference_upload_box.png"
|
||||||
const REGISTRATION_CHOICE_IMAGE_PLACEHOLDER_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/image_placeholder_box.png"
|
const REGISTRATION_CHOICE_IMAGE_PLACEHOLDER_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/image_placeholder_box.png"
|
||||||
const UI_FONT_PATH: String = "res://assets/fonts/msyh.ttc"
|
const UI_FONT_PATH: String = "res://assets/fonts/msyh-web.ttf"
|
||||||
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
|
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
|
||||||
|
const WebFilePicker = preload("res://_Core/utils/WebFilePicker.gd")
|
||||||
const SKIN_GENERATION_CREATE_ENDPOINT: String = "/api/skin-generation/jobs"
|
const SKIN_GENERATION_CREATE_ENDPOINT: String = "/api/skin-generation/jobs"
|
||||||
const SKIN_GENERATION_POLL_ENDPOINT_TEMPLATE: String = "/api/skin-generation/jobs/%s"
|
const SKIN_GENERATION_POLL_ENDPOINT_TEMPLATE: String = "/api/skin-generation/jobs/%s"
|
||||||
const SKIN_GENERATION_POLL_INTERVAL: float = 2.0
|
const SKIN_GENERATION_POLL_INTERVAL: float = 2.0
|
||||||
const SKIN_GENERATION_REQUEST_TIMEOUT: float = 24.0
|
const SKIN_GENERATION_REQUEST_TIMEOUT: float = 24.0
|
||||||
const AVATAR_NATIVE_FILE_FILTERS: Array[String] = ["*.png,*.jpg,*.jpeg,*.webp"]
|
const AVATAR_NATIVE_FILE_FILTERS: Array[String] = ["*.png,*.jpg,*.jpeg,*.webp"]
|
||||||
const SKIN_NATIVE_FILE_FILTERS: Array[String] = ["*.png,*.jpg,*.jpeg,*.webp"]
|
const SKIN_NATIVE_FILE_FILTERS: Array[String] = ["*.png"]
|
||||||
|
const WEB_FILE_MAX_BYTES: int = 8 * 1024 * 1024
|
||||||
const TEXT_COLOR: Color = Color(0.03, 0.12, 0.23, 1.0)
|
const TEXT_COLOR: Color = Color(0.03, 0.12, 0.23, 1.0)
|
||||||
const MUTED_COLOR: Color = Color(0.26, 0.34, 0.44, 1.0)
|
const MUTED_COLOR: Color = Color(0.26, 0.34, 0.44, 1.0)
|
||||||
const ACCENT_COLOR: Color = Color(0.10, 0.38, 0.68, 1.0)
|
const ACCENT_COLOR: Color = Color(0.10, 0.38, 0.68, 1.0)
|
||||||
@@ -91,7 +93,7 @@ var _scene_manager: Node
|
|||||||
var _appearance_manager: Node
|
var _appearance_manager: Node
|
||||||
var _ui_font: FontFile
|
var _ui_font: FontFile
|
||||||
var _brand_font: SystemFont
|
var _brand_font: SystemFont
|
||||||
var _choice_font: SystemFont
|
var _choice_font: Font
|
||||||
var _resuming_cached_session: bool = false
|
var _resuming_cached_session: bool = false
|
||||||
var _cached_resume_start_generation: int = 0
|
var _cached_resume_start_generation: int = 0
|
||||||
var _skin_generation_create_request: HTTPRequest
|
var _skin_generation_create_request: HTTPRequest
|
||||||
@@ -114,13 +116,18 @@ var _awaiting_registration_skin_choice: bool = false
|
|||||||
var _pending_profile_sync_after_auth: bool = false
|
var _pending_profile_sync_after_auth: bool = false
|
||||||
var _deferred_enter_square_after_profile_sync: bool = false
|
var _deferred_enter_square_after_profile_sync: bool = false
|
||||||
var _is_sending_register_code: bool = false
|
var _is_sending_register_code: bool = false
|
||||||
|
var _web_file_picker: RefCounted = WebFilePicker.new()
|
||||||
|
|
||||||
func _enter_tree() -> void:
|
func _enter_tree() -> void:
|
||||||
_ui_font = load(UI_FONT_PATH) as FontFile
|
_ui_font = load(UI_FONT_PATH) as FontFile
|
||||||
_brand_font = SystemFont.new()
|
_brand_font = SystemFont.new()
|
||||||
_brand_font.font_names = PackedStringArray(["Arial Rounded MT Bold", "Avenir Next", "Trebuchet MS", "Arial"])
|
_brand_font.font_names = PackedStringArray(["Arial Rounded MT Bold", "Avenir Next", "Trebuchet MS", "Arial"])
|
||||||
_choice_font = SystemFont.new()
|
if OS.get_name() == "Web":
|
||||||
_choice_font.font_names = PackedStringArray(["STHeiti Medium", "Hiragino Sans GB", "PingFang SC", "Microsoft YaHei"])
|
_choice_font = _ui_font
|
||||||
|
else:
|
||||||
|
var systemFont := SystemFont.new()
|
||||||
|
systemFont.font_names = PackedStringArray(["STHeiti Medium", "Hiragino Sans GB", "PingFang SC", "Microsoft YaHei"])
|
||||||
|
_choice_font = systemFont
|
||||||
_build_v1_auth_ui()
|
_build_v1_auth_ui()
|
||||||
|
|
||||||
func _build_v1_auth_ui() -> void:
|
func _build_v1_auth_ui() -> void:
|
||||||
@@ -680,7 +687,6 @@ func _registration_texture_rect(nodeName: String, texturePath: String, rect: Rec
|
|||||||
return textureRect
|
return textureRect
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
add_to_group("whaletown_escape_dismissible")
|
|
||||||
_auth_manager = get_node_or_null("/root/AuthManager")
|
_auth_manager = get_node_or_null("/root/AuthManager")
|
||||||
_chat_manager = get_node_or_null("/root/ChatManager")
|
_chat_manager = get_node_or_null("/root/ChatManager")
|
||||||
_scene_manager = get_node_or_null("/root/SceneManager")
|
_scene_manager = get_node_or_null("/root/SceneManager")
|
||||||
@@ -690,9 +696,16 @@ func _ready() -> void:
|
|||||||
_connect_signals()
|
_connect_signals()
|
||||||
_refresh_appearance_ui()
|
_refresh_appearance_ui()
|
||||||
_show_login()
|
_show_login()
|
||||||
|
_notify_web_shell_ready()
|
||||||
|
|
||||||
if _auth_manager != null and bool(_auth_manager.call("is_authenticated")) and _should_auto_resume_cached_session():
|
if _auth_manager != null and bool(_auth_manager.call("is_authenticated")) and _should_auto_resume_cached_session():
|
||||||
_resume_cached_session()
|
_resume_cached_session()
|
||||||
|
elif _auth_manager != null and _auth_manager.has_method("consume_browser_bootstrap_kind"):
|
||||||
|
var bootstrapKind := str(_auth_manager.call("consume_browser_bootstrap_kind"))
|
||||||
|
if not bootstrapKind.is_empty():
|
||||||
|
call_deferred("_complete_browser_bootstrap", bootstrapKind)
|
||||||
|
else:
|
||||||
|
login_identifier_input.grab_focus()
|
||||||
else:
|
else:
|
||||||
login_identifier_input.grab_focus()
|
login_identifier_input.grab_focus()
|
||||||
|
|
||||||
@@ -726,6 +739,23 @@ func _connect_signals() -> void:
|
|||||||
_auth_manager.connect("profile_update_succeeded", _on_profile_update_succeeded)
|
_auth_manager.connect("profile_update_succeeded", _on_profile_update_succeeded)
|
||||||
_auth_manager.connect("profile_update_failed", _on_profile_update_failed)
|
_auth_manager.connect("profile_update_failed", _on_profile_update_failed)
|
||||||
_auth_manager.connect("auth_state_changed", _on_auth_state_changed)
|
_auth_manager.connect("auth_state_changed", _on_auth_state_changed)
|
||||||
|
if _auth_manager.has_signal("browser_bootstrap_received"):
|
||||||
|
_auth_manager.connect("browser_bootstrap_received", _complete_browser_bootstrap)
|
||||||
|
|
||||||
|
func _notify_web_shell_ready() -> void:
|
||||||
|
if OS.get_name() != "Web" or not Engine.has_singleton("JavaScriptBridge"):
|
||||||
|
return
|
||||||
|
var bridge: Object = Engine.get_singleton("JavaScriptBridge")
|
||||||
|
bridge.eval("window.whaletownGodotReady && window.whaletownGodotReady()", true)
|
||||||
|
|
||||||
|
func _complete_browser_bootstrap(kind: String) -> void:
|
||||||
|
if _is_submitting:
|
||||||
|
return
|
||||||
|
_set_submitting(true, "正在准备小镇...")
|
||||||
|
if kind == "register":
|
||||||
|
_on_register_succeeded(_auth_manager.call("get_current_user"))
|
||||||
|
return
|
||||||
|
_on_login_succeeded(_auth_manager.call("get_current_user"))
|
||||||
|
|
||||||
func _setup_skin_generation_requests() -> void:
|
func _setup_skin_generation_requests() -> void:
|
||||||
_skin_generation_create_request = HTTPRequest.new()
|
_skin_generation_create_request = HTTPRequest.new()
|
||||||
@@ -1626,16 +1656,10 @@ func _hide_skin_workshop() -> void:
|
|||||||
if is_instance_valid(skin_workshop_overlay):
|
if is_instance_valid(skin_workshop_overlay):
|
||||||
skin_workshop_overlay.hide()
|
skin_workshop_overlay.hide()
|
||||||
|
|
||||||
func is_escape_dismissible() -> bool:
|
|
||||||
return is_instance_valid(skin_workshop_overlay) and skin_workshop_overlay.visible
|
|
||||||
|
|
||||||
func get_escape_priority() -> int:
|
|
||||||
return 900
|
|
||||||
|
|
||||||
func request_escape_close() -> void:
|
|
||||||
_hide_skin_workshop()
|
|
||||||
|
|
||||||
func _on_workshop_source_pressed() -> void:
|
func _on_workshop_source_pressed() -> void:
|
||||||
|
if _open_web_file_picker("workshop"):
|
||||||
|
_set_workshop_generation_status("请选择角色参考图片")
|
||||||
|
return
|
||||||
var err := DisplayServer.file_dialog_show(
|
var err := DisplayServer.file_dialog_show(
|
||||||
"选择角色参考图片",
|
"选择角色参考图片",
|
||||||
_default_picker_dir(),
|
_default_picker_dir(),
|
||||||
@@ -1690,6 +1714,9 @@ func _update_skin_library_buttons() -> void:
|
|||||||
_apply_library_arrow_style(skin_library_next_button, true)
|
_apply_library_arrow_style(skin_library_next_button, true)
|
||||||
|
|
||||||
func _on_upload_avatar_pressed() -> void:
|
func _on_upload_avatar_pressed() -> void:
|
||||||
|
if _open_web_file_picker("avatar"):
|
||||||
|
status_label.text = "请选择头像图片"
|
||||||
|
return
|
||||||
var err := DisplayServer.file_dialog_show(
|
var err := DisplayServer.file_dialog_show(
|
||||||
"选择头像图片",
|
"选择头像图片",
|
||||||
_default_picker_dir(),
|
_default_picker_dir(),
|
||||||
@@ -1708,6 +1735,11 @@ func _on_upload_avatar_pressed() -> void:
|
|||||||
status_label.text = "系统文件选择器未打开:%s / %s" % [DisplayServer.get_name(), error_string(err)]
|
status_label.text = "系统文件选择器未打开:%s / %s" % [DisplayServer.get_name(), error_string(err)]
|
||||||
|
|
||||||
func _on_upload_skin_pressed() -> void:
|
func _on_upload_skin_pressed() -> void:
|
||||||
|
if _open_web_file_picker("skin"):
|
||||||
|
status_label.text = "请选择8x4角色皮肤PNG"
|
||||||
|
if is_instance_valid(workshop_status_label):
|
||||||
|
workshop_status_label.text = "等待选择8x4透明PNG"
|
||||||
|
return
|
||||||
var err := DisplayServer.file_dialog_show(
|
var err := DisplayServer.file_dialog_show(
|
||||||
"选择8x4角色皮肤PNG",
|
"选择8x4角色皮肤PNG",
|
||||||
_default_picker_dir(),
|
_default_picker_dir(),
|
||||||
@@ -1729,6 +1761,46 @@ func _on_upload_skin_pressed() -> void:
|
|||||||
return
|
return
|
||||||
status_label.text = "系统文件选择器未打开:%s / %s" % [DisplayServer.get_name(), error_string(err)]
|
status_label.text = "系统文件选择器未打开:%s / %s" % [DisplayServer.get_name(), error_string(err)]
|
||||||
|
|
||||||
|
func _open_web_file_picker(kind: String) -> bool:
|
||||||
|
if OS.get_name() != "Web" or _web_file_picker == null:
|
||||||
|
return false
|
||||||
|
var accept := ".png" if kind == "skin" else ".png,.jpg,.jpeg,.webp"
|
||||||
|
return bool(_web_file_picker.call("open", accept, WEB_FILE_MAX_BYTES, _on_web_file_selected.bind(kind)))
|
||||||
|
|
||||||
|
func _on_web_file_selected(status: String, fileName: String, _mimeType: String, base64Data: String, kind: String) -> void:
|
||||||
|
if status == "cancel":
|
||||||
|
if kind == "workshop":
|
||||||
|
_set_workshop_generation_status("已取消角色参考图选择")
|
||||||
|
else:
|
||||||
|
status_label.text = "已取消图片选择"
|
||||||
|
return
|
||||||
|
if status == "too_large":
|
||||||
|
var message := "图片不能超过8MB"
|
||||||
|
if kind == "workshop":
|
||||||
|
_set_workshop_generation_status(message)
|
||||||
|
else:
|
||||||
|
status_label.text = message
|
||||||
|
return
|
||||||
|
if status != "ok":
|
||||||
|
var message := "浏览器读取图片失败"
|
||||||
|
if kind == "workshop":
|
||||||
|
_set_workshop_generation_status(message)
|
||||||
|
else:
|
||||||
|
status_label.text = message
|
||||||
|
return
|
||||||
|
|
||||||
|
var path := WebFilePicker.save_base64_file(base64Data, fileName, kind)
|
||||||
|
if path.is_empty():
|
||||||
|
status_label.text = "图片暂存失败"
|
||||||
|
return
|
||||||
|
match kind:
|
||||||
|
"avatar":
|
||||||
|
_on_native_avatar_file_selected(true, PackedStringArray([path]), 0)
|
||||||
|
"skin":
|
||||||
|
_on_native_skin_file_selected(true, PackedStringArray([path]), 0)
|
||||||
|
"workshop":
|
||||||
|
_on_native_workshop_source_file_selected(true, PackedStringArray([path]), 0)
|
||||||
|
|
||||||
func _open_macos_file_dialog(title: String, callbackMethod: String) -> bool:
|
func _open_macos_file_dialog(title: String, callbackMethod: String) -> bool:
|
||||||
if OS.get_name() != "macOS":
|
if OS.get_name() != "macOS":
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ var _resignConfirmButton: Button
|
|||||||
var _resignCancelButton: Button
|
var _resignCancelButton: Button
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
add_to_group("whaletown_escape_dismissible")
|
|
||||||
chatFrame.visible = false
|
chatFrame.visible = false
|
||||||
purchaseButton.pressed.connect(_on_purchase_pressed)
|
purchaseButton.pressed.connect(_on_purchase_pressed)
|
||||||
sendButton.pressed.connect(_on_send_pressed)
|
sendButton.pressed.connect(_on_send_pressed)
|
||||||
@@ -239,18 +238,6 @@ func _on_close_pressed() -> void:
|
|||||||
_waitingForReply = false
|
_waitingForReply = false
|
||||||
chatInput.release_focus()
|
chatInput.release_focus()
|
||||||
|
|
||||||
func is_escape_dismissible() -> bool:
|
|
||||||
return (is_instance_valid(_resignDialog) and _resignDialog.visible) or chatFrame.visible
|
|
||||||
|
|
||||||
func get_escape_priority() -> int:
|
|
||||||
return 850
|
|
||||||
|
|
||||||
func request_escape_close() -> void:
|
|
||||||
if is_instance_valid(_resignDialog) and _resignDialog.visible:
|
|
||||||
_hide_resign_dialog()
|
|
||||||
return
|
|
||||||
_on_close_pressed()
|
|
||||||
|
|
||||||
func _confirm_resign() -> void:
|
func _confirm_resign() -> void:
|
||||||
var servicePointId := str(_resignTarget.get("service_point_id", "")).strip_edges()
|
var servicePointId := str(_resignTarget.get("service_point_id", "")).strip_edges()
|
||||||
if servicePointId.is_empty():
|
if servicePointId.is_empty():
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ var _isSubmitting: bool = false
|
|||||||
var _isFetchingModels: bool = false
|
var _isFetchingModels: bool = false
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
add_to_group("whaletown_escape_dismissible")
|
|
||||||
recruitmentFrame.visible = false
|
recruitmentFrame.visible = false
|
||||||
_setup_protocol_options()
|
_setup_protocol_options()
|
||||||
_setup_employment_duration_options()
|
_setup_employment_duration_options()
|
||||||
@@ -399,15 +398,6 @@ func _on_close_pressed() -> void:
|
|||||||
fetchModelsButton.disabled = false
|
fetchModelsButton.disabled = false
|
||||||
tokenInput.clear()
|
tokenInput.clear()
|
||||||
|
|
||||||
func is_escape_dismissible() -> bool:
|
|
||||||
return recruitmentFrame.visible
|
|
||||||
|
|
||||||
func get_escape_priority() -> int:
|
|
||||||
return 900
|
|
||||||
|
|
||||||
func request_escape_close() -> void:
|
|
||||||
_on_close_pressed()
|
|
||||||
|
|
||||||
func _set_status(message: String, isError: bool) -> void:
|
func _set_status(message: String, isError: bool) -> void:
|
||||||
statusLabel.text = message
|
statusLabel.text = message
|
||||||
statusLabel.add_theme_color_override("font_color", Color(0.68, 0.22, 0.18, 1) if isError else Color(0.32, 0.42, 0.48, 1))
|
statusLabel.add_theme_color_override("font_color", Color(0.68, 0.22, 0.18, 1) if isError else Color(0.32, 0.42, 0.48, 1))
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ var _send_failure_handled_by_ui: bool = false
|
|||||||
|
|
||||||
# 准备就绪
|
# 准备就绪
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
add_to_group("whaletown_escape_dismissible")
|
|
||||||
_configure_mouse_focus()
|
_configure_mouse_focus()
|
||||||
|
|
||||||
# 初始隐藏聊天框
|
# 初始隐藏聊天框
|
||||||
@@ -183,8 +182,6 @@ func _get_settings_manager() -> Node:
|
|||||||
|
|
||||||
# 处理全局输入
|
# 处理全局输入
|
||||||
func _input(event: InputEvent) -> void:
|
func _input(event: InputEvent) -> void:
|
||||||
if get_viewport().is_input_handled():
|
|
||||||
return
|
|
||||||
if event is InputEventMouseButton:
|
if event is InputEventMouseButton:
|
||||||
_handle_global_mouse_button_input(event as InputEventMouseButton)
|
_handle_global_mouse_button_input(event as InputEventMouseButton)
|
||||||
return
|
return
|
||||||
@@ -369,15 +366,6 @@ func hide_chat(immediate: bool = false) -> void:
|
|||||||
_transition_tween.tween_property(chat_panel, "modulate:a", 0.0, CHAT_TRANSITION_DURATION)
|
_transition_tween.tween_property(chat_panel, "modulate:a", 0.0, CHAT_TRANSITION_DURATION)
|
||||||
_transition_tween.finished.connect(_on_hide_transition_finished)
|
_transition_tween.finished.connect(_on_hide_transition_finished)
|
||||||
|
|
||||||
func is_escape_dismissible() -> bool:
|
|
||||||
return _is_chat_visible
|
|
||||||
|
|
||||||
func get_escape_priority() -> int:
|
|
||||||
return 480
|
|
||||||
|
|
||||||
func request_escape_close() -> void:
|
|
||||||
hide_chat()
|
|
||||||
|
|
||||||
# 创建隐藏计时器
|
# 创建隐藏计时器
|
||||||
func _create_hide_timer() -> void:
|
func _create_hide_timer() -> void:
|
||||||
_hide_timer = Timer.new()
|
_hide_timer = Timer.new()
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
[gd_scene load_steps=15 format=3 uid="uid://bv7k2nan4xj8q"]
|
[gd_scene load_steps=16 format=3 uid="uid://bv7k2nan4xj8q"]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://scenes/ui/ChatUI.gd" id="1"]
|
[ext_resource type="Script" path="res://scenes/ui/ChatUI.gd" id="1"]
|
||||||
[ext_resource type="Script" path="res://scenes/ui/BubbleSendButton.gd" id="2"]
|
[ext_resource type="Script" path="res://scenes/ui/BubbleSendButton.gd" id="2"]
|
||||||
|
[ext_resource type="Theme" path="res://assets/ui/world_text_theme.tres" id="3"]
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_panel"]
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_panel"]
|
||||||
bg_color = Color(1, 0.996, 0.984, 0.97)
|
bg_color = Color(1, 0.996, 0.984, 0.97)
|
||||||
@@ -145,6 +146,7 @@ anchor_bottom = 1.0
|
|||||||
grow_horizontal = 2
|
grow_horizontal = 2
|
||||||
grow_vertical = 2
|
grow_vertical = 2
|
||||||
mouse_filter = 2
|
mouse_filter = 2
|
||||||
|
theme = ExtResource("3")
|
||||||
script = ExtResource("1")
|
script = ExtResource("1")
|
||||||
|
|
||||||
[node name="ChatPanel" type="PanelContainer" parent="."]
|
[node name="ChatPanel" type="PanelContainer" parent="."]
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ var _isOpen: bool = false
|
|||||||
var _transitionTween: Tween
|
var _transitionTween: Tween
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
add_to_group("whaletown_escape_dismissible")
|
|
||||||
visible = false
|
visible = false
|
||||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
set_process(false)
|
set_process(false)
|
||||||
@@ -40,8 +39,6 @@ func _notification(what: int) -> void:
|
|||||||
_positionPanel()
|
_positionPanel()
|
||||||
|
|
||||||
func _input(event: InputEvent) -> void:
|
func _input(event: InputEvent) -> void:
|
||||||
if get_viewport().is_input_handled():
|
|
||||||
return
|
|
||||||
if not _isOpen:
|
if not _isOpen:
|
||||||
return
|
return
|
||||||
if event is InputEventKey:
|
if event is InputEventKey:
|
||||||
@@ -75,15 +72,6 @@ func hide_panel() -> void:
|
|||||||
func is_panel_open() -> bool:
|
func is_panel_open() -> bool:
|
||||||
return _isOpen
|
return _isOpen
|
||||||
|
|
||||||
func is_escape_dismissible() -> bool:
|
|
||||||
return _isOpen
|
|
||||||
|
|
||||||
func get_escape_priority() -> int:
|
|
||||||
return 700
|
|
||||||
|
|
||||||
func request_escape_close() -> void:
|
|
||||||
hide_panel()
|
|
||||||
|
|
||||||
func _buildUi() -> void:
|
func _buildUi() -> void:
|
||||||
_overlay = ColorRect.new()
|
_overlay = ColorRect.new()
|
||||||
_overlay.name = "courseBoardDimOverlay"
|
_overlay.name = "courseBoardDimOverlay"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const TEX_TAB_LABEL_PRODUCTIVE = preload("res://assets/ui/datawhale_honor/honor_
|
|||||||
const TEX_TAB_LABEL_SOCIAL = preload("res://assets/ui/datawhale_honor/honor_tab_label_social.png")
|
const TEX_TAB_LABEL_SOCIAL = preload("res://assets/ui/datawhale_honor/honor_tab_label_social.png")
|
||||||
const TEX_TAB_LABEL_RISING = preload("res://assets/ui/datawhale_honor/honor_tab_label_rising.png")
|
const TEX_TAB_LABEL_RISING = preload("res://assets/ui/datawhale_honor/honor_tab_label_rising.png")
|
||||||
const TEX_TAB_LABEL_COMPREHENSIVE = preload("res://assets/ui/datawhale_honor/honor_tab_label_comprehensive.png")
|
const TEX_TAB_LABEL_COMPREHENSIVE = preload("res://assets/ui/datawhale_honor/honor_tab_label_comprehensive.png")
|
||||||
const FONT_UI = preload("res://assets/fonts/msyh.ttc")
|
const FONT_UI = preload("res://assets/fonts/msyh-web.ttf")
|
||||||
const TEX_AVATAR_FALLBACK_1 = preload("res://assets/ui/auth/generated/auth_character_preview_front.png")
|
const TEX_AVATAR_FALLBACK_1 = preload("res://assets/ui/auth/generated/auth_character_preview_front.png")
|
||||||
const TEX_AVATAR_FALLBACK_2 = preload("res://assets/ui/auth/generated/auth_character_preview_left.png")
|
const TEX_AVATAR_FALLBACK_2 = preload("res://assets/ui/auth/generated/auth_character_preview_left.png")
|
||||||
const TEX_AVATAR_FALLBACK_3 = preload("res://assets/ui/auth/generated/auth_character_preview_right.png")
|
const TEX_AVATAR_FALLBACK_3 = preload("res://assets/ui/auth/generated/auth_character_preview_right.png")
|
||||||
@@ -96,7 +96,6 @@ var _isLoading: bool = false
|
|||||||
var _debugShowRankSlots: bool = false
|
var _debugShowRankSlots: bool = false
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
add_to_group("whaletown_escape_dismissible")
|
|
||||||
get_tree().paused = true
|
get_tree().paused = true
|
||||||
_disableChatUiMouseInput()
|
_disableChatUiMouseInput()
|
||||||
_buildUi()
|
_buildUi()
|
||||||
@@ -110,23 +109,12 @@ func _exit_tree() -> void:
|
|||||||
_restoreChatUiMouseInput()
|
_restoreChatUiMouseInput()
|
||||||
|
|
||||||
func _input(event: InputEvent) -> void:
|
func _input(event: InputEvent) -> void:
|
||||||
if get_viewport().is_input_handled():
|
|
||||||
return
|
|
||||||
if event is InputEventKey:
|
if event is InputEventKey:
|
||||||
var keyEvent := event as InputEventKey
|
var keyEvent := event as InputEventKey
|
||||||
if keyEvent.pressed and not keyEvent.echo and keyEvent.keycode == KEY_ESCAPE:
|
if keyEvent.pressed and not keyEvent.echo and keyEvent.keycode == KEY_ESCAPE:
|
||||||
_onClosePressed()
|
_onClosePressed()
|
||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
func is_escape_dismissible() -> bool:
|
|
||||||
return is_inside_tree()
|
|
||||||
|
|
||||||
func get_escape_priority() -> int:
|
|
||||||
return 1000
|
|
||||||
|
|
||||||
func request_escape_close() -> void:
|
|
||||||
_onClosePressed()
|
|
||||||
|
|
||||||
func _buildUi() -> void:
|
func _buildUi() -> void:
|
||||||
_rootControl = Control.new()
|
_rootControl = Control.new()
|
||||||
_rootControl.name = "RankingPanelRoot"
|
_rootControl.name = "RankingPanelRoot"
|
||||||
@@ -1138,8 +1126,8 @@ func _podiumMetaLine(data: Dictionary) -> String:
|
|||||||
if location.is_empty():
|
if location.is_empty():
|
||||||
return domain
|
return domain
|
||||||
if domain.is_empty():
|
if domain.is_empty():
|
||||||
return "📍 %s" % location
|
return location
|
||||||
return "📍 %s · %s" % [location, domain]
|
return "%s · %s" % [location, domain]
|
||||||
|
|
||||||
func _podiumTags(data: Dictionary) -> Array[String]:
|
func _podiumTags(data: Dictionary) -> Array[String]:
|
||||||
var tags: Array[String] = []
|
var tags: Array[String] = []
|
||||||
@@ -1177,7 +1165,13 @@ func _avatarPrimaryTextureFromData(data: Dictionary) -> Texture2D:
|
|||||||
return null
|
return null
|
||||||
|
|
||||||
func _avatarUrlFromData(data: Dictionary) -> String:
|
func _avatarUrlFromData(data: Dictionary) -> String:
|
||||||
return _firstNonEmptyString(data, ["avatarUrl", "avatar_url", "avatar", "avatarURL"])
|
var avatarUrl := _firstNonEmptyString(data, ["avatarUrl", "avatar_url", "avatar", "avatarURL"])
|
||||||
|
if not avatarUrl.begins_with("/"):
|
||||||
|
return avatarUrl
|
||||||
|
var apiBaseUrl := NetworkConfig.get_api_base_url()
|
||||||
|
if avatarUrl.begins_with("/api/") and apiBaseUrl.ends_with("/api"):
|
||||||
|
return apiBaseUrl.trim_suffix("/api") + avatarUrl
|
||||||
|
return apiBaseUrl + avatarUrl
|
||||||
|
|
||||||
func _firstNonEmptyString(data: Dictionary, keys: Array[String]) -> String:
|
func _firstNonEmptyString(data: Dictionary, keys: Array[String]) -> String:
|
||||||
for key in keys:
|
for key in keys:
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ var _isOpen: bool = false
|
|||||||
var _hasRequestedFriendList: bool = false
|
var _hasRequestedFriendList: bool = false
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
add_to_group("whaletown_escape_dismissible")
|
|
||||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
_build_ui()
|
_build_ui()
|
||||||
set_panel_open(false)
|
set_panel_open(false)
|
||||||
@@ -61,15 +60,6 @@ func set_panel_open(open: bool) -> void:
|
|||||||
func is_panel_open() -> bool:
|
func is_panel_open() -> bool:
|
||||||
return _isOpen
|
return _isOpen
|
||||||
|
|
||||||
func is_escape_dismissible() -> bool:
|
|
||||||
return _isOpen
|
|
||||||
|
|
||||||
func get_escape_priority() -> int:
|
|
||||||
return 500
|
|
||||||
|
|
||||||
func request_escape_close() -> void:
|
|
||||||
set_panel_open(false)
|
|
||||||
|
|
||||||
func toggle_panel() -> void:
|
func toggle_panel() -> void:
|
||||||
set_panel_open(not _isOpen)
|
set_panel_open(not _isOpen)
|
||||||
|
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ const MAP_CONFIGS: Dictionary = {
|
|||||||
"YSortWorld",
|
"YSortWorld",
|
||||||
],
|
],
|
||||||
"markers": [
|
"markers": [
|
||||||
{"label": "码头", "destinationId": "square_dock", "icon": "anchor", "pos": Vector2(0.160, 0.395), "side": "right"},
|
{"label": "码头", "icon": "anchor", "pos": Vector2(0.160, 0.395), "side": "right"},
|
||||||
{"label": "总部", "destinationId": "square_headquarters", "icon": "home", "pos": Vector2(0.505, 0.188), "side": "right"},
|
{"label": "总部", "icon": "home", "pos": Vector2(0.505, 0.188), "side": "right"},
|
||||||
{"label": "广场", "destinationId": "square_center", "icon": "whale", "pos": Vector2(0.505, 0.515), "side": "right"},
|
{"label": "广场", "icon": "whale", "pos": Vector2(0.505, 0.515), "side": "right"},
|
||||||
{"label": "小屋", "destinationId": "square_cottage", "icon": "home", "pos": Vector2(0.830, 0.400), "side": "right"},
|
{"label": "小屋", "icon": "home", "pos": Vector2(0.830, 0.400), "side": "right"},
|
||||||
{"label": "工坊", "destinationId": "square_workshop", "icon": "tool", "pos": Vector2(0.752, 0.760), "side": "left"},
|
{"label": "工坊", "icon": "tool", "pos": Vector2(0.752, 0.760), "side": "left"},
|
||||||
{"label": "公告", "destinationId": "square_notice", "icon": "notice", "pos": Vector2(0.288, 0.838), "side": "right"},
|
{"label": "公告", "icon": "notice", "pos": Vector2(0.288, 0.838), "side": "right"},
|
||||||
{"label": "入口", "destinationId": "square_work_zone_gate", "icon": "gate", "pos": Vector2(0.500, 0.900), "side": "right"},
|
{"label": "入口", "icon": "gate", "pos": Vector2(0.500, 0.900), "side": "right"},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"WorkZone": {
|
"WorkZone": {
|
||||||
@@ -57,13 +57,13 @@ const MAP_CONFIGS: Dictionary = {
|
|||||||
"YSortWorld",
|
"YSortWorld",
|
||||||
],
|
],
|
||||||
"markers": [
|
"markers": [
|
||||||
{"label": "商城", "destinationId": "work_mall", "icon": "home", "pos": Vector2(0.500, 0.180), "side": "right"},
|
{"label": "商城", "icon": "home", "pos": Vector2(0.500, 0.180), "side": "right"},
|
||||||
{"label": "咖啡店", "destinationId": "work_cafe_gate", "icon": "home", "pos": Vector2(0.092, 0.620), "side": "right"},
|
{"label": "咖啡店", "icon": "home", "pos": Vector2(0.092, 0.620), "side": "right"},
|
||||||
{"label": "任务", "destinationId": "work_jobs", "icon": "notice", "pos": Vector2(0.304, 0.600), "side": "right"},
|
{"label": "任务", "icon": "notice", "pos": Vector2(0.304, 0.600), "side": "right"},
|
||||||
{"label": "课程", "destinationId": "work_courses", "icon": "notice", "pos": Vector2(0.694, 0.500), "side": "left"},
|
{"label": "课程", "icon": "notice", "pos": Vector2(0.694, 0.500), "side": "left"},
|
||||||
{"label": "AI站", "destinationId": "work_ai", "icon": "tool", "pos": Vector2(0.676, 0.785), "side": "left"},
|
{"label": "AI站", "icon": "tool", "pos": Vector2(0.676, 0.785), "side": "left"},
|
||||||
{"label": "鲸币", "destinationId": "work_exchange", "icon": "whale", "pos": Vector2(0.920, 0.785), "side": "left"},
|
{"label": "鲸币", "icon": "whale", "pos": Vector2(0.920, 0.785), "side": "left"},
|
||||||
{"label": "入口", "destinationId": "work_entrance", "icon": "gate", "pos": Vector2(0.500, 0.900), "side": "right"},
|
{"label": "入口", "icon": "gate", "pos": Vector2(0.500, 0.900), "side": "right"},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"CafeInterior": {
|
"CafeInterior": {
|
||||||
@@ -74,19 +74,11 @@ const MAP_CONFIGS: Dictionary = {
|
|||||||
"CafeServiceHallBase",
|
"CafeServiceHallBase",
|
||||||
],
|
],
|
||||||
"markers": [
|
"markers": [
|
||||||
{"label": "服务台", "destinationId": "cafe_counter", "icon": "whale", "pos": Vector2(0.500, 0.465), "side": "right"},
|
{"label": "服务台", "icon": "whale", "pos": Vector2(0.500, 0.465), "side": "right"},
|
||||||
{"label": "陪伴区", "destinationId": "cafe_companion", "icon": "notice", "pos": Vector2(0.240, 0.280), "side": "right"},
|
{"label": "陪伴区", "icon": "notice", "pos": Vector2(0.240, 0.280), "side": "right"},
|
||||||
{"label": "出口", "destinationId": "cafe_entrance", "icon": "gate", "pos": Vector2(0.500, 0.855), "side": "right"},
|
{"label": "出口", "icon": "gate", "pos": Vector2(0.500, 0.855), "side": "right"},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"PersonalSpace": {
|
|
||||||
"title": "我的房间",
|
|
||||||
"worldSize": Vector2(1024, 768),
|
|
||||||
"sourceNodes": [
|
|
||||||
"RoomBase",
|
|
||||||
],
|
|
||||||
"markers": [],
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var _panel: PanelContainer
|
var _panel: PanelContainer
|
||||||
@@ -98,13 +90,8 @@ var _mapCamera: Camera2D
|
|||||||
var _titleLabel: Label
|
var _titleLabel: Label
|
||||||
var _markerNodes: Array[Control] = []
|
var _markerNodes: Array[Control] = []
|
||||||
var _isOpen: bool = false
|
var _isOpen: bool = false
|
||||||
var _travelDestinations: Dictionary = {}
|
|
||||||
var _travelLocked: bool = false
|
|
||||||
var _destinationMenu: PopupMenu
|
|
||||||
var _destinationMenuIds: Dictionary = {}
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
add_to_group("whaletown_escape_dismissible")
|
|
||||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
_build_ui()
|
_build_ui()
|
||||||
set_panel_open(false)
|
set_panel_open(false)
|
||||||
@@ -120,8 +107,6 @@ func _notification(what: int) -> void:
|
|||||||
_anchor_panel()
|
_anchor_panel()
|
||||||
|
|
||||||
func _input(event: InputEvent) -> void:
|
func _input(event: InputEvent) -> void:
|
||||||
if get_viewport().is_input_handled():
|
|
||||||
return
|
|
||||||
if not _isOpen:
|
if not _isOpen:
|
||||||
return
|
return
|
||||||
if event is InputEventKey:
|
if event is InputEventKey:
|
||||||
@@ -138,20 +123,10 @@ func set_panel_open(open: bool) -> void:
|
|||||||
_panel.visible = _isOpen
|
_panel.visible = _isOpen
|
||||||
if _isOpen:
|
if _isOpen:
|
||||||
_rebuild_minimap_world()
|
_rebuild_minimap_world()
|
||||||
_load_travel_destinations()
|
|
||||||
|
|
||||||
func is_panel_open() -> bool:
|
func is_panel_open() -> bool:
|
||||||
return _isOpen
|
return _isOpen
|
||||||
|
|
||||||
func is_escape_dismissible() -> bool:
|
|
||||||
return _isOpen
|
|
||||||
|
|
||||||
func get_escape_priority() -> int:
|
|
||||||
return 600
|
|
||||||
|
|
||||||
func request_escape_close() -> void:
|
|
||||||
set_panel_open(false)
|
|
||||||
|
|
||||||
func toggle_panel() -> void:
|
func toggle_panel() -> void:
|
||||||
set_panel_open(not _isOpen)
|
set_panel_open(not _isOpen)
|
||||||
|
|
||||||
@@ -177,9 +152,6 @@ func _build_ui() -> void:
|
|||||||
|
|
||||||
content.add_child(_build_header())
|
content.add_child(_build_header())
|
||||||
content.add_child(_build_map_view())
|
content.add_child(_build_map_view())
|
||||||
_destinationMenu = PopupMenu.new()
|
|
||||||
_destinationMenu.id_pressed.connect(_on_destination_menu_selected)
|
|
||||||
add_child(_destinationMenu)
|
|
||||||
|
|
||||||
func _build_header() -> Control:
|
func _build_header() -> Control:
|
||||||
var header := HBoxContainer.new()
|
var header := HBoxContainer.new()
|
||||||
@@ -201,20 +173,6 @@ func _build_header() -> Control:
|
|||||||
title.add_theme_font_size_override("font_size", 24)
|
title.add_theme_font_size_override("font_size", 24)
|
||||||
header.add_child(title)
|
header.add_child(title)
|
||||||
|
|
||||||
var destinationButton := Button.new()
|
|
||||||
destinationButton.text = "目的地"
|
|
||||||
destinationButton.tooltip_text = "查看全部快速传送目的地"
|
|
||||||
destinationButton.custom_minimum_size = Vector2(76, 38)
|
|
||||||
destinationButton.focus_mode = Control.FOCUS_NONE
|
|
||||||
destinationButton.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
|
||||||
destinationButton.add_theme_font_size_override("font_size", 14)
|
|
||||||
destinationButton.add_theme_color_override("font_color", ACCENT_COLOR)
|
|
||||||
destinationButton.add_theme_stylebox_override("normal", _create_round_style(Color(0.902, 0.962, 0.995, 1.0), 14))
|
|
||||||
destinationButton.add_theme_stylebox_override("hover", _create_round_style(Color(0.818, 0.925, 0.980, 1.0), 14))
|
|
||||||
destinationButton.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
|
|
||||||
destinationButton.pressed.connect(func() -> void: _show_destination_menu(destinationButton))
|
|
||||||
header.add_child(destinationButton)
|
|
||||||
|
|
||||||
var closeButton := Button.new()
|
var closeButton := Button.new()
|
||||||
closeButton.text = "×"
|
closeButton.text = "×"
|
||||||
closeButton.tooltip_text = "关闭地图"
|
closeButton.tooltip_text = "关闭地图"
|
||||||
@@ -305,16 +263,6 @@ func _create_marker(marker: Dictionary) -> Control:
|
|||||||
button.add_theme_stylebox_override("hover", _create_marker_style(Color(0.925, 0.966, 0.996, 0.98), ACCENT_COLOR))
|
button.add_theme_stylebox_override("hover", _create_marker_style(Color(0.925, 0.966, 0.996, 0.98), ACCENT_COLOR))
|
||||||
button.add_theme_stylebox_override("pressed", _create_marker_style(Color(0.858, 0.925, 0.980, 0.98), ACCENT_COLOR.darkened(0.05)))
|
button.add_theme_stylebox_override("pressed", _create_marker_style(Color(0.858, 0.925, 0.980, 0.98), ACCENT_COLOR.darkened(0.05)))
|
||||||
button.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
|
button.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
|
||||||
var destinationId := str(marker.get("destinationId", "")).strip_edges()
|
|
||||||
var destination: Dictionary = _travelDestinations.get(destinationId, {})
|
|
||||||
var unlocked := destinationId.is_empty() or _travelDestinations.is_empty() or bool(destination.get("unlocked", false))
|
|
||||||
button.disabled = not unlocked or _travelLocked
|
|
||||||
button.tooltip_text = "点击快速传送" if unlocked else "首次到访后解锁"
|
|
||||||
if not unlocked:
|
|
||||||
button.modulate = Color(0.66, 0.69, 0.72, 0.72)
|
|
||||||
button.pressed.connect(func() -> void: return)
|
|
||||||
else:
|
|
||||||
button.pressed.connect(func() -> void: _request_travel(destinationId))
|
|
||||||
|
|
||||||
var row := HBoxContainer.new()
|
var row := HBoxContainer.new()
|
||||||
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
@@ -333,7 +281,7 @@ func _create_marker(marker: Dictionary) -> Control:
|
|||||||
|
|
||||||
var label := Label.new()
|
var label := Label.new()
|
||||||
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
label.text = ("🔒 " if not unlocked else "") + str(marker.get("label", "地点"))
|
label.text = str(marker.get("label", "地点"))
|
||||||
label.add_theme_color_override("font_color", TEXT_COLOR)
|
label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||||
label.add_theme_font_size_override("font_size", 14)
|
label.add_theme_font_size_override("font_size", 14)
|
||||||
row.add_child(label)
|
row.add_child(label)
|
||||||
@@ -347,117 +295,6 @@ func _create_marker(marker: Dictionary) -> Control:
|
|||||||
)
|
)
|
||||||
return button
|
return button
|
||||||
|
|
||||||
func _load_travel_destinations() -> void:
|
|
||||||
var api := get_node_or_null("/root/ApiClient")
|
|
||||||
if api == null:
|
|
||||||
return
|
|
||||||
api.call("get_json", "/world/travel-destinations", func(success: bool, response: Dictionary, _error: Dictionary) -> void:
|
|
||||||
if not success:
|
|
||||||
return
|
|
||||||
var data_variant: Variant = response.get("data", [])
|
|
||||||
if not (data_variant is Array):
|
|
||||||
return
|
|
||||||
_travelDestinations.clear()
|
|
||||||
for entry in data_variant as Array:
|
|
||||||
if entry is Dictionary:
|
|
||||||
var destination: Dictionary = entry
|
|
||||||
_travelDestinations[str(destination.get("id", ""))] = destination
|
|
||||||
_render_markers()
|
|
||||||
, true)
|
|
||||||
|
|
||||||
func _show_destination_menu(origin: Control) -> void:
|
|
||||||
if not is_instance_valid(_destinationMenu):
|
|
||||||
return
|
|
||||||
_destinationMenu.clear()
|
|
||||||
_destinationMenuIds.clear()
|
|
||||||
if _travelDestinations.is_empty():
|
|
||||||
_destinationMenu.add_item("正在读取目的地…", 0)
|
|
||||||
_destinationMenu.set_item_disabled(0, true)
|
|
||||||
else:
|
|
||||||
var destinations: Array[Dictionary] = []
|
|
||||||
for destination_variant in _travelDestinations.values():
|
|
||||||
if destination_variant is Dictionary:
|
|
||||||
destinations.append(destination_variant as Dictionary)
|
|
||||||
destinations.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
|
||||||
var map_order := {"whale_port": 0, "work_zone": 1, "whale_cafe": 2, "personal_space": 3}
|
|
||||||
var order_a := int(map_order.get(str(a.get("mapId", "")), 9))
|
|
||||||
var order_b := int(map_order.get(str(b.get("mapId", "")), 9))
|
|
||||||
if order_a != order_b:
|
|
||||||
return order_a < order_b
|
|
||||||
return str(a.get("label", "")).naturalnocasecmp_to(str(b.get("label", ""))) < 0
|
|
||||||
)
|
|
||||||
var current_map := ""
|
|
||||||
var menu_id := 1
|
|
||||||
for destination in destinations:
|
|
||||||
var map_id := str(destination.get("mapId", ""))
|
|
||||||
if map_id != current_map:
|
|
||||||
if not current_map.is_empty():
|
|
||||||
_destinationMenu.add_separator()
|
|
||||||
_destinationMenu.add_item("— %s —" % _map_label(map_id), menu_id)
|
|
||||||
_destinationMenu.set_item_disabled(_destinationMenu.item_count - 1, true)
|
|
||||||
menu_id += 1
|
|
||||||
current_map = map_id
|
|
||||||
var unlocked := bool(destination.get("unlocked", false))
|
|
||||||
_destinationMenu.add_item(("" if unlocked else "🔒 ") + str(destination.get("label", "地点")), menu_id)
|
|
||||||
_destinationMenu.set_item_disabled(_destinationMenu.item_count - 1, not unlocked or _travelLocked)
|
|
||||||
_destinationMenuIds[menu_id] = str(destination.get("id", ""))
|
|
||||||
menu_id += 1
|
|
||||||
_destinationMenu.reset_size()
|
|
||||||
_destinationMenu.position = Vector2i(origin.get_screen_position() + Vector2(0.0, origin.size.y))
|
|
||||||
_destinationMenu.popup()
|
|
||||||
|
|
||||||
func _on_destination_menu_selected(menu_id: int) -> void:
|
|
||||||
var destination_id := str(_destinationMenuIds.get(menu_id, ""))
|
|
||||||
if not destination_id.is_empty():
|
|
||||||
_request_travel(destination_id)
|
|
||||||
|
|
||||||
func _request_travel(destinationId: String) -> void:
|
|
||||||
if destinationId.is_empty() or _travelLocked:
|
|
||||||
return
|
|
||||||
var socialManager := get_node_or_null("/root/SocialManager")
|
|
||||||
if socialManager == null or not socialManager.has_method("request_travel"):
|
|
||||||
return
|
|
||||||
_travelLocked = true
|
|
||||||
_render_markers()
|
|
||||||
socialManager.call("request_travel", destinationId, Callable(self, "_on_travel_authorized"))
|
|
||||||
|
|
||||||
func _on_travel_authorized(success: bool, destination: Dictionary) -> void:
|
|
||||||
if not success:
|
|
||||||
await get_tree().create_timer(1.0).timeout
|
|
||||||
_travelLocked = false
|
|
||||||
_render_markers()
|
|
||||||
return
|
|
||||||
var mapId := str(destination.get("mapId", ""))
|
|
||||||
var position := _scene_position_for_destination(mapId, Vector2(float(destination.get("x", 0.0)), float(destination.get("y", 0.0))))
|
|
||||||
var sceneName: String = str({"whale_port": "square", "work_zone": "work_zone", "whale_cafe": "cafe_interior", "personal_space": "personal_space"}.get(mapId, ""))
|
|
||||||
if sceneName.is_empty():
|
|
||||||
await get_tree().create_timer(1.0).timeout
|
|
||||||
_travelLocked = false
|
|
||||||
_render_markers()
|
|
||||||
return
|
|
||||||
set_panel_open(false)
|
|
||||||
SceneManager.set_next_destination_id(str(destination.get("id", "")))
|
|
||||||
SceneManager.set_next_scene_position(position)
|
|
||||||
SceneManager.change_scene(sceneName)
|
|
||||||
|
|
||||||
func _scene_position_for_destination(map_id: String, map_position: Vector2) -> Vector2:
|
|
||||||
# 服务端旅行目录使用小地图坐标;游戏场景则以地图中心为原点。
|
|
||||||
var origin := {
|
|
||||||
"whale_port": Vector2(1280, 960),
|
|
||||||
"work_zone": Vector2(1280, 960),
|
|
||||||
"whale_cafe": Vector2(768, 512),
|
|
||||||
"personal_space": Vector2(768, 512),
|
|
||||||
}.get(map_id, Vector2.ZERO) as Vector2
|
|
||||||
return map_position - origin
|
|
||||||
|
|
||||||
func _map_label(map_id: String) -> String:
|
|
||||||
return {
|
|
||||||
"whale_port": "中心广场",
|
|
||||||
"work_zone": "打工区",
|
|
||||||
"whale_cafe": "鲸鱼咖啡馆",
|
|
||||||
"personal_space": "我的房间",
|
|
||||||
}.get(map_id, map_id)
|
|
||||||
|
|
||||||
func _anchor_panel() -> void:
|
func _anchor_panel() -> void:
|
||||||
_panel.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
_panel.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||||
_panel.offset_left = -PANEL_SIZE.x - PANEL_MARGIN_RIGHT
|
_panel.offset_left = -PANEL_SIZE.x - PANEL_MARGIN_RIGHT
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ var _chatUiPrevMouseFilter: Control.MouseFilter = Control.MOUSE_FILTER_STOP
|
|||||||
var _chatUiMouseDisabled: bool = false
|
var _chatUiMouseDisabled: bool = false
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
add_to_group("whaletown_escape_dismissible")
|
|
||||||
get_tree().paused = true
|
get_tree().paused = true
|
||||||
_disableChatUiMouseInput()
|
_disableChatUiMouseInput()
|
||||||
|
|
||||||
@@ -40,15 +39,6 @@ func _ready() -> void:
|
|||||||
func _exit_tree() -> void:
|
func _exit_tree() -> void:
|
||||||
_restoreChatUiMouseInput()
|
_restoreChatUiMouseInput()
|
||||||
|
|
||||||
func is_escape_dismissible() -> bool:
|
|
||||||
return is_inside_tree()
|
|
||||||
|
|
||||||
func get_escape_priority() -> int:
|
|
||||||
return 1000
|
|
||||||
|
|
||||||
func request_escape_close() -> void:
|
|
||||||
_onClosePressed()
|
|
||||||
|
|
||||||
func _setupDots() -> void:
|
func _setupDots() -> void:
|
||||||
for child in dotsContainer.get_children():
|
for child in dotsContainer.get_children():
|
||||||
child.queue_free()
|
child.queue_free()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ extends Control
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
const HUD_MARGIN: Vector2 = Vector2(16, 16)
|
const HUD_MARGIN: Vector2 = Vector2(16, 16)
|
||||||
const SHORTCUT_BAR_SIZE: Vector2 = Vector2(406, 86)
|
const SHORTCUT_BAR_SIZE: Vector2 = Vector2(350, 86)
|
||||||
const PLAYER_PROFILE_BUTTON_SIZE: Vector2 = Vector2(210, 78)
|
const PLAYER_PROFILE_BUTTON_SIZE: Vector2 = Vector2(210, 78)
|
||||||
const PLAYER_AVATAR_SIZE: Vector2 = Vector2(58, 58)
|
const PLAYER_AVATAR_SIZE: Vector2 = Vector2(58, 58)
|
||||||
const HUD_SEPARATION: int = 18
|
const HUD_SEPARATION: int = 18
|
||||||
@@ -95,7 +95,6 @@ func _build_shortcut_bar() -> PanelContainer:
|
|||||||
row.add_child(_create_shortcut_button("task", "任务", _on_task_pressed))
|
row.add_child(_create_shortcut_button("task", "任务", _on_task_pressed))
|
||||||
row.add_child(_create_shortcut_button("backpack", "背包", _on_backpack_pressed))
|
row.add_child(_create_shortcut_button("backpack", "背包", _on_backpack_pressed))
|
||||||
row.add_child(_create_shortcut_button("friends", "好友", _on_friends_pressed))
|
row.add_child(_create_shortcut_button("friends", "好友", _on_friends_pressed))
|
||||||
row.add_child(_create_shortcut_button("activity", "通知", _on_notifications_pressed))
|
|
||||||
row.add_child(_create_shortcut_button("settings", "设置", _on_settings_pressed))
|
row.add_child(_create_shortcut_button("settings", "设置", _on_settings_pressed))
|
||||||
|
|
||||||
return panel
|
return panel
|
||||||
@@ -380,11 +379,6 @@ func _on_map_pressed() -> void:
|
|||||||
if eventSystem != null:
|
if eventSystem != null:
|
||||||
eventSystem.call("emit_event", EventNames.HUD_MAP_TOGGLE, {})
|
eventSystem.call("emit_event", EventNames.HUD_MAP_TOGGLE, {})
|
||||||
|
|
||||||
func _on_notifications_pressed() -> void:
|
|
||||||
var socialManager := get_node_or_null("/root/SocialManager")
|
|
||||||
if socialManager != null and socialManager.has_method("toggle_notifications"):
|
|
||||||
socialManager.call("toggle_notifications")
|
|
||||||
|
|
||||||
func _on_task_pressed() -> void:
|
func _on_task_pressed() -> void:
|
||||||
_emit_status_message("任务入口稍后接入")
|
_emit_status_message("任务入口稍后接入")
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[gd_scene load_steps=2 format=3]
|
[gd_scene load_steps=3 format=3]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://scenes/ui/PlayerHud.gd" id="1"]
|
[ext_resource type="Script" path="res://scenes/ui/PlayerHud.gd" id="1"]
|
||||||
|
[ext_resource type="Theme" path="res://assets/ui/world_text_theme.tres" id="2_theme"]
|
||||||
|
|
||||||
[node name="PlayerHud" type="Control"]
|
[node name="PlayerHud" type="Control"]
|
||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
@@ -10,4 +11,5 @@ anchor_bottom = 1.0
|
|||||||
grow_horizontal = 2
|
grow_horizontal = 2
|
||||||
grow_vertical = 2
|
grow_vertical = 2
|
||||||
mouse_filter = 2
|
mouse_filter = 2
|
||||||
|
theme = ExtResource("2_theme")
|
||||||
script = ExtResource("1")
|
script = ExtResource("1")
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const ICON_SCRIPT: Script = preload("res://scenes/ui/SettingsPanelIcon.gd")
|
|||||||
const TOGGLE_SCRIPT: Script = preload("res://scenes/ui/SettingsToggle.gd")
|
const TOGGLE_SCRIPT: Script = preload("res://scenes/ui/SettingsToggle.gd")
|
||||||
const SLIDER_SCRIPT: Script = preload("res://scenes/ui/SettingsSlider.gd")
|
const SLIDER_SCRIPT: Script = preload("res://scenes/ui/SettingsSlider.gd")
|
||||||
const BADGE_TEXTURE_PATH: String = "res://assets/ui/settings/settings_whale_badge_asset_v1.png"
|
const BADGE_TEXTURE_PATH: String = "res://assets/ui/settings/settings_whale_badge_asset_v1.png"
|
||||||
|
const WebFilePicker = preload("res://_Core/utils/WebFilePicker.gd")
|
||||||
|
|
||||||
const TEXT_COLOR: Color = Color(0.188, 0.294, 0.424)
|
const TEXT_COLOR: Color = Color(0.188, 0.294, 0.424)
|
||||||
const MUTED_COLOR: Color = Color(0.560, 0.639, 0.733)
|
const MUTED_COLOR: Color = Color(0.560, 0.639, 0.733)
|
||||||
@@ -22,6 +23,7 @@ const SOFT_BLUE: Color = Color(0.918, 0.961, 0.992, 0.92)
|
|||||||
const DANGER_COLOR: Color = Color(0.894, 0.392, 0.357)
|
const DANGER_COLOR: Color = Color(0.894, 0.392, 0.357)
|
||||||
const MOVEMENT_ACTIONS: Array[String] = ["move_left", "move_right", "move_up", "move_down"]
|
const MOVEMENT_ACTIONS: Array[String] = ["move_left", "move_right", "move_up", "move_down"]
|
||||||
const AVATAR_NATIVE_FILE_FILTERS: Array[String] = ["*.png,*.jpg,*.jpeg,*.webp"]
|
const AVATAR_NATIVE_FILE_FILTERS: Array[String] = ["*.png,*.jpg,*.jpeg,*.webp"]
|
||||||
|
const WEB_FILE_MAX_BYTES: int = 8 * 1024 * 1024
|
||||||
|
|
||||||
const CATEGORIES: Array[Dictionary] = [
|
const CATEGORIES: Array[Dictionary] = [
|
||||||
{"id": "basic", "label": "基础", "icon": "basic"},
|
{"id": "basic", "label": "基础", "icon": "basic"},
|
||||||
@@ -38,7 +40,6 @@ const DEFAULT_SETTINGS: Dictionary = {
|
|||||||
"ui_scale": 1.00,
|
"ui_scale": 1.00,
|
||||||
"fullscreen": false,
|
"fullscreen": false,
|
||||||
"show_interaction_hints": true,
|
"show_interaction_hints": true,
|
||||||
"show_interaction_points": false,
|
|
||||||
"show_name_always": false,
|
"show_name_always": false,
|
||||||
"show_chat_bubbles": true,
|
"show_chat_bubbles": true,
|
||||||
"world_notifications": true,
|
"world_notifications": true,
|
||||||
@@ -46,7 +47,6 @@ const DEFAULT_SETTINGS: Dictionary = {
|
|||||||
"friend_request_notifications": true,
|
"friend_request_notifications": true,
|
||||||
"allow_nearby_private": true,
|
"allow_nearby_private": true,
|
||||||
"allow_nearby_friend_requests": true,
|
"allow_nearby_friend_requests": true,
|
||||||
"allow_nearby_profile": true,
|
|
||||||
"mute_ui_sfx": false,
|
"mute_ui_sfx": false,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,9 +73,9 @@ var _savedSettings: Dictionary = DEFAULT_SETTINGS.duplicate(true)
|
|||||||
var _isOpen: bool = false
|
var _isOpen: bool = false
|
||||||
var _transitionTween: Tween
|
var _transitionTween: Tween
|
||||||
var _avatarUploadPending: bool = false
|
var _avatarUploadPending: bool = false
|
||||||
|
var _webFilePicker: RefCounted = WebFilePicker.new()
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
add_to_group("whaletown_escape_dismissible")
|
|
||||||
visible = false
|
visible = false
|
||||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
set_process(false)
|
set_process(false)
|
||||||
@@ -98,8 +98,6 @@ func _notification(what: int) -> void:
|
|||||||
_position_panel()
|
_position_panel()
|
||||||
|
|
||||||
func _input(event: InputEvent) -> void:
|
func _input(event: InputEvent) -> void:
|
||||||
if get_viewport().is_input_handled():
|
|
||||||
return
|
|
||||||
if not _isOpen:
|
if not _isOpen:
|
||||||
return
|
return
|
||||||
if event is InputEventKey:
|
if event is InputEventKey:
|
||||||
@@ -141,15 +139,6 @@ func hide_panel(saveBeforeClose: bool = false) -> void:
|
|||||||
func is_panel_open() -> bool:
|
func is_panel_open() -> bool:
|
||||||
return _isOpen
|
return _isOpen
|
||||||
|
|
||||||
func is_escape_dismissible() -> bool:
|
|
||||||
return _isOpen
|
|
||||||
|
|
||||||
func get_escape_priority() -> int:
|
|
||||||
return 700
|
|
||||||
|
|
||||||
func request_escape_close() -> void:
|
|
||||||
hide_panel(true)
|
|
||||||
|
|
||||||
func _build_ui() -> void:
|
func _build_ui() -> void:
|
||||||
_overlay = ColorRect.new()
|
_overlay = ColorRect.new()
|
||||||
_overlay.name = "SettingsDimOverlay"
|
_overlay.name = "SettingsDimOverlay"
|
||||||
@@ -358,7 +347,6 @@ func _render_basic_content() -> void:
|
|||||||
_content.add_child(_create_section("基础设置", [
|
_content.add_child(_create_section("基础设置", [
|
||||||
_create_toggle_row("window", "全屏显示", "fullscreen", "适合专注游玩,窗口模式方便调试"),
|
_create_toggle_row("window", "全屏显示", "fullscreen", "适合专注游玩,窗口模式方便调试"),
|
||||||
_create_toggle_row("eye", "显示互动提示", "show_interaction_hints", "靠近 NPC、好友或公告板时显示按键提示"),
|
_create_toggle_row("eye", "显示互动提示", "show_interaction_hints", "靠近 NPC、好友或公告板时显示按键提示"),
|
||||||
_create_toggle_row("eye", "显示交互点", "show_interaction_points", "用白色圆圈直接标出当前地图内的交互目标"),
|
|
||||||
_create_toggle_row("account", "始终显示名字", "show_name_always", "关闭时只在需要时显示玩家名称"),
|
_create_toggle_row("account", "始终显示名字", "show_name_always", "关闭时只在需要时显示玩家名称"),
|
||||||
]))
|
]))
|
||||||
_content.add_child(_create_section("音频设置", [
|
_content.add_child(_create_section("音频设置", [
|
||||||
@@ -380,27 +368,25 @@ func _render_chat_content() -> void:
|
|||||||
_content.add_child(_create_section("聊天与社交", [
|
_content.add_child(_create_section("聊天与社交", [
|
||||||
_create_toggle_row("chat", "世界频道提醒", "world_notifications", "收到世界频道消息时保留轻提示"),
|
_create_toggle_row("chat", "世界频道提醒", "world_notifications", "收到世界频道消息时保留轻提示"),
|
||||||
_create_toggle_row("chat", "私聊提醒", "private_notifications", "好友或附近玩家私聊时提示"),
|
_create_toggle_row("chat", "私聊提醒", "private_notifications", "好友或附近玩家私聊时提示"),
|
||||||
_create_toggle_row("account", "好友请求提醒", "friend_request_notifications", "附近玩家发起好友申请时显示在好友列表"),
|
_create_toggle_row("account", "好友请求提醒", "friend_request_notifications", "对方按 F 发起好友申请时显示在好友列表"),
|
||||||
_create_toggle_row("chat", "显示聊天气泡", "show_chat_bubbles", "气泡发送会同时进入世界频道"),
|
_create_toggle_row("chat", "显示聊天气泡", "show_chat_bubbles", "气泡发送会同时进入世界频道"),
|
||||||
]))
|
]))
|
||||||
_content.add_child(_create_section("附近玩家权限", [
|
_content.add_child(_create_section("附近玩家权限", [
|
||||||
_create_toggle_row("eye", "允许查看我的名片", "allow_nearby_profile", "关闭后陌生玩家无法在附近打开你的社区名片"),
|
|
||||||
_create_toggle_row("chat", "允许附近私聊", "allow_nearby_private", "附近玩家按 E 可以发起悄悄话"),
|
_create_toggle_row("chat", "允许附近私聊", "allow_nearby_private", "附近玩家按 E 可以发起悄悄话"),
|
||||||
_create_toggle_row("account", "允许好友申请", "allow_nearby_friend_requests", "附近玩家可通过互动列表发送好友申请"),
|
_create_toggle_row("account", "允许好友申请", "allow_nearby_friend_requests", "附近玩家按 F 可以发送好友申请"),
|
||||||
]))
|
]))
|
||||||
|
|
||||||
func _render_controls_content() -> void:
|
func _render_controls_content() -> void:
|
||||||
_content.add_child(_create_section("操作说明", [
|
_content.add_child(_create_section("操作说明", [
|
||||||
_create_key_row("W A S D", "移动角色"),
|
_create_key_row("W A S D", "移动角色"),
|
||||||
_create_key_row("方向键", "附近互动列表选择"),
|
_create_key_row("方向键", "备用移动"),
|
||||||
_create_key_row("E", "执行选中的互动"),
|
_create_key_row("E", "互动 / 附近私聊"),
|
||||||
_create_key_row("Esc", "关闭当前界面或结束当前互动"),
|
_create_key_row("F", "发送好友申请"),
|
||||||
_create_key_row("T", "打开聊天输入"),
|
_create_key_row("T", "打开聊天输入"),
|
||||||
_create_key_row("Enter", "发送聊天消息"),
|
_create_key_row("Enter", "发送聊天消息"),
|
||||||
]))
|
]))
|
||||||
_content.add_child(_create_section("操作辅助", [
|
_content.add_child(_create_section("操作辅助", [
|
||||||
_create_toggle_row("eye", "显示互动提示", "show_interaction_hints", "靠近可交互目标时显示按键提示"),
|
_create_toggle_row("eye", "显示互动提示", "show_interaction_hints", "靠近可交互目标时显示按键提示"),
|
||||||
_create_toggle_row("eye", "显示交互点", "show_interaction_points", "用白色圆圈直接标出当前地图内的交互目标"),
|
|
||||||
_create_hint_row("当前版本使用固定键位;这里的开关会控制地图内的互动提示显示。"),
|
_create_hint_row("当前版本使用固定键位;这里的开关会控制地图内的互动提示显示。"),
|
||||||
]))
|
]))
|
||||||
|
|
||||||
@@ -993,6 +979,9 @@ func _on_reconnect_pressed() -> void:
|
|||||||
_statusLabel.text = "已请求重新连接聊天服务"
|
_statusLabel.text = "已请求重新连接聊天服务"
|
||||||
|
|
||||||
func _on_upload_avatar_pressed() -> void:
|
func _on_upload_avatar_pressed() -> void:
|
||||||
|
if _try_open_web_avatar_file_picker():
|
||||||
|
_set_account_status("请选择头像图片")
|
||||||
|
return
|
||||||
if _open_macos_avatar_file_dialog():
|
if _open_macos_avatar_file_dialog():
|
||||||
return
|
return
|
||||||
if _try_open_native_avatar_file_picker():
|
if _try_open_native_avatar_file_picker():
|
||||||
@@ -1000,6 +989,27 @@ func _on_upload_avatar_pressed() -> void:
|
|||||||
return
|
return
|
||||||
_set_account_status("系统文件选择器暂时不可用")
|
_set_account_status("系统文件选择器暂时不可用")
|
||||||
|
|
||||||
|
func _try_open_web_avatar_file_picker() -> bool:
|
||||||
|
if OS.get_name() != "Web" or _webFilePicker == null:
|
||||||
|
return false
|
||||||
|
return bool(_webFilePicker.call("open", ".png,.jpg,.jpeg,.webp", WEB_FILE_MAX_BYTES, _on_web_avatar_file_selected))
|
||||||
|
|
||||||
|
func _on_web_avatar_file_selected(status: String, fileName: String, _mimeType: String, base64Data: String) -> void:
|
||||||
|
if status == "cancel":
|
||||||
|
_set_account_status("已取消头像选择")
|
||||||
|
return
|
||||||
|
if status == "too_large":
|
||||||
|
_set_account_status("头像图片不能超过8MB")
|
||||||
|
return
|
||||||
|
if status != "ok":
|
||||||
|
_set_account_status("浏览器读取头像失败")
|
||||||
|
return
|
||||||
|
var path := WebFilePicker.save_base64_file(base64Data, fileName, "avatar")
|
||||||
|
if path.is_empty():
|
||||||
|
_set_account_status("头像图片暂存失败")
|
||||||
|
return
|
||||||
|
_on_avatar_file_selected(path)
|
||||||
|
|
||||||
func _try_open_native_avatar_file_picker() -> bool:
|
func _try_open_native_avatar_file_picker() -> bool:
|
||||||
if DisplayServer.get_name() == "headless":
|
if DisplayServer.get_name() == "headless":
|
||||||
return false
|
return false
|
||||||
|
|||||||
9
scenes/ui/WebBootstrap.gd
Normal file
9
scenes/ui/WebBootstrap.gd
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
extends Control
|
||||||
|
|
||||||
|
var _scene_manager: Node
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_scene_manager = get_node_or_null("/root/SceneManager")
|
||||||
|
if _scene_manager == null:
|
||||||
|
return
|
||||||
|
_scene_manager.call_deferred("change_scene", "auth", false)
|
||||||
1
scenes/ui/WebBootstrap.gd.uid
Normal file
1
scenes/ui/WebBootstrap.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://22fxlhvp012i
|
||||||
12
scenes/ui/WebBootstrap.tscn
Normal file
12
scenes/ui/WebBootstrap.tscn
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scenes/ui/WebBootstrap.gd" id="1_bootstrap"]
|
||||||
|
|
||||||
|
[node name="WebBootstrap" type="Control"]
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
script = ExtResource("1_bootstrap")
|
||||||
@@ -11,7 +11,7 @@ const GUIDE_PAGES: Array[Dictionary] = [
|
|||||||
"image_path": "res://assets/maps/square/v1/props/center_whale_fountain_v2_hd_clean.png",
|
"image_path": "res://assets/maps/square/v1/props/center_whale_fountain_v2_hd_clean.png",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "操作提示:\n\n- 按 [color=#ffaa00]E[/color] 键可以与 NPC、公告板和信息板互动。\n- 靠近目标后面向它,再按互动键。\n- 按 [color=#ffaa00]Esc[/color] 可以关闭当前界面或结束当前互动。\n- 输入框获得焦点时,角色移动会暂停响应。",
|
"text": "操作提示:\n\n- 按 [color=#ffaa00]E[/color] 键可以与 NPC、公告板和信息板互动。\n- 靠近目标后面向它,再按互动键。\n- 输入框获得焦点时,角色移动会暂停响应。",
|
||||||
"image_path": "res://assets/maps/square/v1/props/bottom_entrance_right_service_props_v2_hd_clean.png",
|
"image_path": "res://assets/maps/square/v1/props/bottom_entrance_right_service_props_v2_hd_clean.png",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -38,7 +38,6 @@ var _guideCurrentPage: int = 0
|
|||||||
var _guideTween: Tween
|
var _guideTween: Tween
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
add_to_group("whaletown_escape_dismissible")
|
|
||||||
_disableChatUiMouseInput()
|
_disableChatUiMouseInput()
|
||||||
|
|
||||||
var closeButton := find_child("CloseButton", true, false) as Button
|
var closeButton := find_child("CloseButton", true, false) as Button
|
||||||
@@ -53,20 +52,8 @@ func _exit_tree() -> void:
|
|||||||
_restoreChatUiMouseInput()
|
_restoreChatUiMouseInput()
|
||||||
|
|
||||||
func _input(event: InputEvent) -> void:
|
func _input(event: InputEvent) -> void:
|
||||||
if get_viewport().is_input_handled():
|
|
||||||
return
|
|
||||||
if event.is_action_pressed("ui_cancel"):
|
if event.is_action_pressed("ui_cancel"):
|
||||||
queue_free()
|
queue_free()
|
||||||
get_viewport().set_input_as_handled()
|
|
||||||
|
|
||||||
func is_escape_dismissible() -> bool:
|
|
||||||
return is_inside_tree()
|
|
||||||
|
|
||||||
func get_escape_priority() -> int:
|
|
||||||
return 980
|
|
||||||
|
|
||||||
func request_escape_close() -> void:
|
|
||||||
queue_free()
|
|
||||||
|
|
||||||
func _onClosePressed() -> void:
|
func _onClosePressed() -> void:
|
||||||
queue_free()
|
queue_free()
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ var _transitionTween: Tween
|
|||||||
var _toastTween: Tween
|
var _toastTween: Tween
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
add_to_group("whaletown_escape_dismissible")
|
|
||||||
visible = false
|
visible = false
|
||||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
set_process(false)
|
set_process(false)
|
||||||
@@ -83,8 +82,6 @@ func _notification(what: int) -> void:
|
|||||||
_position_panel()
|
_position_panel()
|
||||||
|
|
||||||
func _input(event: InputEvent) -> void:
|
func _input(event: InputEvent) -> void:
|
||||||
if get_viewport().is_input_handled():
|
|
||||||
return
|
|
||||||
if not _isOpen:
|
if not _isOpen:
|
||||||
return
|
return
|
||||||
if event is InputEventKey:
|
if event is InputEventKey:
|
||||||
@@ -123,18 +120,6 @@ func hide_panel() -> void:
|
|||||||
func is_panel_open() -> bool:
|
func is_panel_open() -> bool:
|
||||||
return _isOpen
|
return _isOpen
|
||||||
|
|
||||||
func is_escape_dismissible() -> bool:
|
|
||||||
return _isOpen
|
|
||||||
|
|
||||||
func get_escape_priority() -> int:
|
|
||||||
return 700
|
|
||||||
|
|
||||||
func request_escape_close() -> void:
|
|
||||||
if is_instance_valid(_confirmDialog) and _confirmDialog.visible:
|
|
||||||
_hide_confirm_dialog()
|
|
||||||
return
|
|
||||||
hide_panel()
|
|
||||||
|
|
||||||
func _build_ui() -> void:
|
func _build_ui() -> void:
|
||||||
_overlay = ColorRect.new()
|
_overlay = ColorRect.new()
|
||||||
_overlay.name = "mallDimOverlay"
|
_overlay.name = "mallDimOverlay"
|
||||||
|
|||||||
57
scripts/build_progressive_web.sh
Executable file
57
scripts/build_progressive_web.sh
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
GODOT_BIN=${GODOT_BIN:-/Applications/Godot.app/Contents/MacOS/Godot}
|
||||||
|
WEB_DIR="$PROJECT_DIR/build/web"
|
||||||
|
PACK_DIR="$WEB_DIR/packs"
|
||||||
|
|
||||||
|
if [ ! -x "$GODOT_BIN" ]; then
|
||||||
|
echo "Godot executable not found: $GODOT_BIN" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$WEB_DIR" "$PACK_DIR"
|
||||||
|
find "$PACK_DIR" -maxdepth 1 -type f \( -name '*.pck' -o -name 'manifest.json' \) -delete
|
||||||
|
find "$WEB_DIR" -maxdepth 1 -type f -name 'index-*.pck' -delete
|
||||||
|
|
||||||
|
"$GODOT_BIN" --headless --path "$PROJECT_DIR" --export-release "Web" "$WEB_DIR/index.html"
|
||||||
|
|
||||||
|
core_pack_path="$WEB_DIR/index.pck"
|
||||||
|
core_hash=$(shasum -a 256 "$core_pack_path" | awk '{print substr($1, 1, 12)}')
|
||||||
|
core_pack_name="index-$core_hash.pck"
|
||||||
|
core_pack_size=$(stat -f '%z' "$core_pack_path")
|
||||||
|
mv "$core_pack_path" "$WEB_DIR/$core_pack_name"
|
||||||
|
perl -0pi -e "s/__WHALETOWN_CORE_PACK__/$core_pack_name/g; s/__WHALETOWN_CORE_PACK_SIZE__/$core_pack_size/g" "$WEB_DIR/index.html"
|
||||||
|
|
||||||
|
build_pack() {
|
||||||
|
pack_key=$1
|
||||||
|
preset=$2
|
||||||
|
temp_path="$PACK_DIR/$pack_key.pck"
|
||||||
|
"$GODOT_BIN" --headless --path "$PROJECT_DIR" --export-pack "$preset" "$temp_path" >&2
|
||||||
|
hash=$(shasum -a 256 "$temp_path" | awk '{print substr($1, 1, 12)}')
|
||||||
|
file_name="$pack_key-$hash.pck"
|
||||||
|
mv "$temp_path" "$PACK_DIR/$file_name"
|
||||||
|
size=$(stat -f '%z' "$PACK_DIR/$file_name")
|
||||||
|
jq -n --arg file "$file_name" --argjson size "$size" '{file: $file, size: $size}'
|
||||||
|
}
|
||||||
|
|
||||||
|
square=$(build_pack "square" "Scene Square")
|
||||||
|
work_zone=$(build_pack "work_zone" "Scene Work Zone")
|
||||||
|
cafe=$(build_pack "cafe_interior" "Scene Cafe")
|
||||||
|
personal_space=$(build_pack "personal_space" "Scene Personal Space")
|
||||||
|
auth=$(build_pack "auth" "Scene Auth")
|
||||||
|
|
||||||
|
jq -n \
|
||||||
|
--argjson square "$square" \
|
||||||
|
--argjson work_zone "$work_zone" \
|
||||||
|
--argjson cafe_interior "$cafe" \
|
||||||
|
--argjson personal_space "$personal_space" \
|
||||||
|
--argjson auth "$auth" \
|
||||||
|
'{version: 1, packs: {auth: $auth, square: $square, work_zone: $work_zone, cafe_interior: $cafe_interior, personal_space: $personal_space}}' \
|
||||||
|
> "$PACK_DIR/manifest.json"
|
||||||
|
|
||||||
|
cp "$PROJECT_DIR/web/auth-background.jpg" "$WEB_DIR/auth-background.jpg"
|
||||||
|
|
||||||
|
echo "Progressive web build created in $WEB_DIR"
|
||||||
|
du -h "$WEB_DIR/$core_pack_name" "$WEB_DIR/index.wasm" "$WEB_DIR/auth-background.jpg" "$PACK_DIR"/*.pck
|
||||||
BIN
web/auth-background.jpg
Normal file
BIN
web/auth-background.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 256 KiB |
40
web/auth-background.jpg.import
Normal file
40
web/auth-background.jpg.import
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://dtn7hj6yayxcy"
|
||||||
|
path="res://.godot/imported/auth-background.jpg-05cdcc917d4b33aab75636638294862f.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://web/auth-background.jpg"
|
||||||
|
dest_files=["res://.godot/imported/auth-background.jpg-05cdcc917d4b33aab75636638294862f.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
|
||||||
87
web/progressive_shell.html
Normal file
87
web/progressive_shell.html
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>$GODOT_PROJECT_NAME</title>
|
||||||
|
$GODOT_HEAD_INCLUDE
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light; font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #b9d9df; }
|
||||||
|
#canvas { position: fixed; inset: 0; display: block; width: 100%; height: 100%; border: 0; background: #0a2437; }
|
||||||
|
#loading-shell { position: fixed; inset: 0; z-index: 10; display: grid; place-items: end center; padding: 0 24px max(9vh, 34px); background: #b9d9df url("auth-background.jpg") center / cover no-repeat; transition: opacity .18s ease; }
|
||||||
|
#loading-shell::before { content: ""; position: absolute; inset: 0; background: rgba(222, 242, 244, .22); }
|
||||||
|
.loading { position: relative; width: min(420px, 88vw); color: #174f72; text-align: center; text-shadow: 0 1px 0 rgba(255, 255, 255, .9); }
|
||||||
|
.status { min-height: 24px; margin: 0 0 10px; font-size: 15px; font-weight: 700; line-height: 1.5; letter-spacing: 0; }
|
||||||
|
.track { height: 7px; overflow: hidden; border: 1px solid rgba(23, 79, 114, .28); border-radius: 4px; background: rgba(255, 255, 255, .78); box-shadow: 0 2px 8px rgba(15, 65, 89, .16); }
|
||||||
|
.bar { width: 0; height: 100%; background: #2b86b6; transition: width .2s ease; }
|
||||||
|
.failure { display: none; margin-top: 10px; color: #8f2634; font-size: 13px; line-height: 1.5; }
|
||||||
|
#loading-shell.done { opacity: 0; pointer-events: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="canvas">当前浏览器不支持游戏画布。</canvas>
|
||||||
|
<div id="loading-shell" aria-live="polite">
|
||||||
|
<div class="loading">
|
||||||
|
<p class="status" id="status">正在加载小镇 0%</p>
|
||||||
|
<div class="track" aria-hidden="true"><div class="bar" id="bar"></div></div>
|
||||||
|
<div class="failure" id="failure" role="alert"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<noscript>当前浏览器未启用 JavaScript,无法运行鲸鱼小镇。</noscript>
|
||||||
|
<script src="$GODOT_URL"></script>
|
||||||
|
<script>
|
||||||
|
const GODOT_CONFIG = $GODOT_CONFIG;
|
||||||
|
const GODOT_THREADS_ENABLED = $GODOT_THREADS_ENABLED;
|
||||||
|
const CORE_PACK_URL = '__WHALETOWN_CORE_PACK__';
|
||||||
|
const CORE_PACK_SIZE = __WHALETOWN_CORE_PACK_SIZE__;
|
||||||
|
GODOT_CONFIG.mainPack = CORE_PACK_URL;
|
||||||
|
GODOT_CONFIG.fileSizes[CORE_PACK_URL] = CORE_PACK_SIZE;
|
||||||
|
const shell = document.getElementById('loading-shell');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
const bar = document.getElementById('bar');
|
||||||
|
const failure = document.getElementById('failure');
|
||||||
|
const engine = new Engine(GODOT_CONFIG);
|
||||||
|
|
||||||
|
window.whaletownGodotReady = function () {
|
||||||
|
bar.style.width = '100%';
|
||||||
|
shell.classList.add('done');
|
||||||
|
window.setTimeout(function () { shell.style.display = 'none'; }, 220);
|
||||||
|
document.getElementById('canvas').focus();
|
||||||
|
};
|
||||||
|
window.whaletownPackProgress = function (downloaded, total) {
|
||||||
|
const downloadedMb = downloaded / 1048576;
|
||||||
|
if (total > 0) {
|
||||||
|
const totalMb = total / 1048576;
|
||||||
|
const percent = Math.min(100, Math.round(downloaded / total * 100));
|
||||||
|
bar.style.width = percent + '%';
|
||||||
|
status.textContent = '正在加载完整登录界面 ' + downloadedMb.toFixed(1) + ' / ' + totalMb.toFixed(1) + ' MB';
|
||||||
|
} else {
|
||||||
|
status.textContent = '正在加载完整登录界面 ' + downloadedMb.toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const missing = Engine.getMissingFeatures({ threads: GODOT_THREADS_ENABLED });
|
||||||
|
if (missing.length) {
|
||||||
|
failure.style.display = 'block';
|
||||||
|
failure.textContent = '当前浏览器缺少运行游戏所需能力:' + missing.join('、');
|
||||||
|
} else {
|
||||||
|
engine.startGame({
|
||||||
|
onProgress(current, total) {
|
||||||
|
if (total <= 0) return;
|
||||||
|
const percent = Math.min(100, Math.round(current / total * 100));
|
||||||
|
bar.style.width = percent + '%';
|
||||||
|
status.textContent = '正在加载小镇 ' + percent + '%';
|
||||||
|
},
|
||||||
|
}).then(function () {
|
||||||
|
status.textContent = '正在加载完整登录界面...';
|
||||||
|
bar.style.width = '0';
|
||||||
|
}).catch(function (error) {
|
||||||
|
failure.style.display = 'block';
|
||||||
|
failure.textContent = error && error.message ? error.message : '主程序加载失败,请刷新重试';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user