Compare commits
6 Commits
main
...
feature-so
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3accf6b71 | ||
|
|
9a3248abd6 | ||
|
|
bb91ccea21 | ||
|
|
aa19f157f2 | ||
|
|
74a0fb8309 | ||
|
|
9dee47492c |
11
README.md
11
README.md
@@ -16,6 +16,17 @@ WhaleTown V2 前端是基于 Godot 4.6 的 2D 多人小镇客户端。
|
||||
2. 用 Godot 打开 `project.godot`。
|
||||
3. 运行默认主场景。
|
||||
|
||||
## 导出
|
||||
|
||||
仓库包含 Web 和 Linux 导出预设。安装 Godot 4.6 导出模板后可执行:
|
||||
|
||||
```bash
|
||||
godot --headless --path . --export-release Web build/web/index.html
|
||||
godot --headless --path . --export-release "Linux/X11" build/linux/WhaleTown-V2.x86_64
|
||||
```
|
||||
|
||||
Web 产物是静态文件,必须通过 HTTP/HTTPS 服务器发布,不要直接用 `file://` 打开。
|
||||
|
||||
默认连接 WhaleTown 生产 API 和 WebSocket 服务。本地联调时可使用以下环境变量覆盖:
|
||||
|
||||
- `WHALETOWN_API_BASE_URL`
|
||||
|
||||
@@ -82,6 +82,8 @@ const CHAT_LOGIN_FAILED = "chat_login_failed"
|
||||
const CHAT_PRIVATE_TARGET_SELECTED = "chat_private_target_selected"
|
||||
const CHAT_FRIEND_SELECTED = "chat_friend_selected"
|
||||
const CHAT_FRIENDS_UPDATED = "chat_friends_updated"
|
||||
const SOCIAL_NOTIFICATION_RECEIVED = "social_notification_received"
|
||||
const SOCIAL_PROFILE_UPDATED = "social_profile_updated"
|
||||
|
||||
# ============================================================================
|
||||
# 咖啡店陪伴机器人事件
|
||||
@@ -105,6 +107,8 @@ const HUD_FRIEND_LIST_TOGGLE = "hud_friend_list_toggle"
|
||||
const HUD_SETTINGS_REQUESTED = "hud_settings_requested"
|
||||
const HUD_BACKPACK_TOGGLE = "hud_backpack_toggle"
|
||||
const HUD_MAP_TOGGLE = "hud_map_toggle"
|
||||
const HUD_TASK_BOOK_REQUESTED = "hud_task_book_requested"
|
||||
const HUD_ADMIN_TEST_LAB_REQUESTED = "hud_admin_test_lab_requested"
|
||||
|
||||
# ============================================================================
|
||||
# 商城事件
|
||||
|
||||
100
_Core/interactions/InteractableComponent.gd
Normal file
100
_Core/interactions/InteractableComponent.gd
Normal file
@@ -0,0 +1,100 @@
|
||||
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
_Core/interactions/InteractableComponent.gd.uid
Normal file
1
_Core/interactions/InteractableComponent.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cn3fccr0qc41h
|
||||
20
_Core/interactions/InteractionAction.gd
Normal file
20
_Core/interactions/InteractionAction.gd
Normal file
@@ -0,0 +1,20 @@
|
||||
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
_Core/interactions/InteractionAction.gd.uid
Normal file
1
_Core/interactions/InteractionAction.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dbrui8h3sbjrn
|
||||
@@ -33,6 +33,9 @@ func patch_json(endpoint: String, payload: Dictionary, callback: Callable, authe
|
||||
func put_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true) -> void:
|
||||
request_json(endpoint, payload, callback, HTTPClient.METHOD_PUT, authenticated)
|
||||
|
||||
func delete_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true) -> void:
|
||||
request_json(endpoint, payload, callback, HTTPClient.METHOD_DELETE, authenticated)
|
||||
|
||||
func request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_GET, authenticated: bool = true) -> void:
|
||||
var request := HTTPRequest.new()
|
||||
request.timeout = REQUEST_TIMEOUT
|
||||
|
||||
@@ -32,6 +32,7 @@ var _session_generation: int = 0
|
||||
var _account_generation: int = 0
|
||||
var _refresh_in_flight: bool = false
|
||||
var _auth_config_path: String = DEFAULT_AUTH_CONFIG_PATH
|
||||
var _show_welcome_after_registration: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
_load_cached_session()
|
||||
@@ -70,6 +71,11 @@ func get_account_generation() -> int:
|
||||
func get_auth_config_path() -> String:
|
||||
return _auth_config_path
|
||||
|
||||
func consume_registration_welcome() -> bool:
|
||||
var should_show: bool = _show_welcome_after_registration
|
||||
_show_welcome_after_registration = false
|
||||
return should_show
|
||||
|
||||
func login(identifier: String, password: String) -> void:
|
||||
var normalized_identifier := identifier.strip_edges()
|
||||
if normalized_identifier.is_empty():
|
||||
@@ -297,6 +303,7 @@ func _on_register_response(success: bool, data: Dictionary, error_info: Dictiona
|
||||
register_failed.emit("注册响应缺少 access_token")
|
||||
return
|
||||
|
||||
_show_welcome_after_registration = true
|
||||
_emit_event(EventNames.AUTH_REGISTER_SUCCESS, {
|
||||
"user": get_current_user()
|
||||
})
|
||||
|
||||
@@ -979,6 +979,12 @@ func _on_data_received(message: String) -> void:
|
||||
_handle_chat_error(data)
|
||||
"chat_render":
|
||||
_handle_chat_render(data)
|
||||
"dm_message":
|
||||
_handle_direct_message(data)
|
||||
"dm_read":
|
||||
_emit_event(EventNames.SOCIAL_NOTIFICATION_RECEIVED, {"type": "dm_read", "data": data})
|
||||
"notification_created", "friendship_changed", "friend_presence_changed":
|
||||
_emit_event(EventNames.SOCIAL_NOTIFICATION_RECEIVED, {"type": message_type, "data": data})
|
||||
"friend_list":
|
||||
_handle_friend_list(data)
|
||||
"friend_added":
|
||||
@@ -1301,6 +1307,41 @@ func _handle_chat_render(data: Dictionary) -> void:
|
||||
"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 字符串)
|
||||
func _parse_chat_timestamp_to_unix(timestamp_raw: Variant) -> float:
|
||||
if typeof(timestamp_raw) == TYPE_INT or typeof(timestamp_raw) == TYPE_FLOAT:
|
||||
@@ -1511,12 +1552,14 @@ func _normalize_friend_requests(requests_variant: Variant) -> Array[Dictionary]:
|
||||
if not (request_variant is Dictionary):
|
||||
continue
|
||||
var request: Dictionary = request_variant
|
||||
var user_id := str(request.get("userId", request.get("user_id", ""))).strip_edges()
|
||||
var requester_variant: Variant = request.get("requester", {})
|
||||
var requester: Dictionary = requester_variant if requester_variant is Dictionary else {}
|
||||
var user_id := str(request.get("userId", request.get("user_id", requester.get("id", "")))).strip_edges()
|
||||
if user_id.is_empty():
|
||||
continue
|
||||
requests.append({
|
||||
"user_id": user_id,
|
||||
"username": str(request.get("username", "玩家")).strip_edges(),
|
||||
"username": str(request.get("username", requester.get("nickname", requester.get("username", "玩家")))).strip_edges(),
|
||||
"created_at": str(request.get("createdAt", request.get("created_at", ""))).strip_edges()
|
||||
})
|
||||
return requests
|
||||
|
||||
245
_Core/managers/InteractionManager.gd
Normal file
245
_Core/managers/InteractionManager.gd
Normal file
@@ -0,0 +1,245 @@
|
||||
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
_Core/managers/InteractionManager.gd.uid
Normal file
1
_Core/managers/InteractionManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cg46uuk3hxvqe
|
||||
@@ -38,6 +38,7 @@ var current_scene_name: String = "" # 当前场景名称
|
||||
var is_changing_scene: bool = false # 是否正在切换场景
|
||||
var _next_scene_position: Variant = null # 下一个场景的初始位置 (Vector2 or null)
|
||||
var _next_spawn_name: String = "" # 下一个场景的出生点名称 (String)
|
||||
var _next_destination_id: String = "" # 快速传送目标,用于发现登记
|
||||
|
||||
# 场景路径映射表
|
||||
# 将场景名称映射到实际的文件路径
|
||||
@@ -194,6 +195,14 @@ func get_next_spawn_name() -> String:
|
||||
_next_spawn_name = ""
|
||||
return spawn_name
|
||||
|
||||
func set_next_destination_id(destination_id: String) -> void:
|
||||
_next_destination_id = destination_id.strip_edges()
|
||||
|
||||
func get_next_destination_id() -> String:
|
||||
var destination_id := _next_destination_id
|
||||
_next_destination_id = ""
|
||||
return destination_id
|
||||
|
||||
# ============ 场景注册方法 ============
|
||||
|
||||
# 注册新场景
|
||||
|
||||
@@ -15,7 +15,8 @@ const DEFAULT_SETTINGS: Dictionary = {
|
||||
"effects_volume": 0.90,
|
||||
"ui_scale": 1.00,
|
||||
"fullscreen": false,
|
||||
"show_interaction_hints": false,
|
||||
"show_interaction_hints": true,
|
||||
"show_interaction_points": false,
|
||||
"show_name_always": false,
|
||||
"show_chat_bubbles": true,
|
||||
"world_notifications": true,
|
||||
@@ -23,6 +24,7 @@ const DEFAULT_SETTINGS: Dictionary = {
|
||||
"friend_request_notifications": true,
|
||||
"allow_nearby_private": true,
|
||||
"allow_nearby_friend_requests": true,
|
||||
"allow_nearby_profile": true,
|
||||
"mute_ui_sfx": false,
|
||||
}
|
||||
|
||||
|
||||
438
_Core/managers/SocialManager.gd
Normal file
438
_Core/managers/SocialManager.gd
Normal file
@@ -0,0 +1,438 @@
|
||||
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
_Core/managers/SocialManager.gd.uid
Normal file
1
_Core/managers/SocialManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cm5em8mgr5i24
|
||||
128
_Core/managers/TaskManager.gd
Normal file
128
_Core/managers/TaskManager.gd
Normal file
@@ -0,0 +1,128 @@
|
||||
extends Node
|
||||
|
||||
# 玩家任务书状态与后端任务接口的唯一入口。
|
||||
signal board_changed(board: Dictionary)
|
||||
signal board_failed(message: String)
|
||||
signal reward_claimed(task_id: String, wallet: Dictionary)
|
||||
|
||||
var _board: Dictionary = {}
|
||||
var _loading_board: bool = false
|
||||
var _account_generation: int = -1
|
||||
|
||||
func _ready() -> void:
|
||||
var auth_manager := get_node_or_null("/root/AuthManager")
|
||||
if auth_manager != null and auth_manager.has_signal("auth_state_changed"):
|
||||
var callback := Callable(self, "_on_auth_state_changed")
|
||||
if not auth_manager.is_connected("auth_state_changed", callback):
|
||||
auth_manager.connect("auth_state_changed", callback)
|
||||
call_deferred("_refresh_after_ready")
|
||||
|
||||
func get_board() -> Dictionary:
|
||||
return _board.duplicate(true)
|
||||
|
||||
func is_loaded() -> bool:
|
||||
return not _board.is_empty()
|
||||
|
||||
func refresh_board() -> void:
|
||||
if not _is_authenticated() or _loading_board:
|
||||
return
|
||||
var api := _api_client()
|
||||
if api == null:
|
||||
board_failed.emit("任务服务不可用")
|
||||
return
|
||||
_loading_board = true
|
||||
_account_generation = _current_account_generation()
|
||||
api.call("get_json", "/tasks/board", Callable(self, "_on_board_response").bind(_account_generation), true)
|
||||
|
||||
func report_activity(activity: String, target_id: String = "") -> void:
|
||||
if not _is_authenticated():
|
||||
return
|
||||
var api := _api_client()
|
||||
if api == null:
|
||||
return
|
||||
var payload := {"activity": activity}
|
||||
if not target_id.strip_edges().is_empty():
|
||||
payload["target_id"] = target_id.strip_edges()
|
||||
var request_generation := _current_account_generation()
|
||||
api.call("post_json", "/tasks/activities", payload, Callable(self, "_on_activity_response").bind(request_generation), true)
|
||||
|
||||
func claim_task(task_id: String) -> void:
|
||||
if task_id.strip_edges().is_empty() or not _is_authenticated():
|
||||
return
|
||||
var api := _api_client()
|
||||
if api == null:
|
||||
board_failed.emit("任务服务不可用")
|
||||
return
|
||||
var request_generation := _current_account_generation()
|
||||
api.call("post_json", "/tasks/%s/claim" % task_id.uri_encode(), {}, Callable(self, "_on_claim_response").bind(task_id, request_generation), true)
|
||||
|
||||
func _refresh_after_ready() -> void:
|
||||
if _is_authenticated():
|
||||
refresh_board()
|
||||
|
||||
func _on_auth_state_changed(is_authenticated: bool, _user: Dictionary) -> void:
|
||||
if not is_authenticated:
|
||||
_board.clear()
|
||||
_loading_board = false
|
||||
_account_generation = -1
|
||||
board_changed.emit({})
|
||||
return
|
||||
refresh_board()
|
||||
|
||||
func _on_board_response(success: bool, response: Dictionary, error_info: Dictionary, request_generation: int) -> void:
|
||||
if request_generation != _current_account_generation():
|
||||
return
|
||||
_loading_board = false
|
||||
if not success:
|
||||
board_failed.emit(str(error_info.get("message", "任务书读取失败")))
|
||||
return
|
||||
_apply_board_response(response)
|
||||
|
||||
func _on_activity_response(success: bool, response: Dictionary, _error_info: Dictionary, request_generation: int) -> void:
|
||||
if not success or request_generation != _current_account_generation():
|
||||
return
|
||||
_apply_board_response(response)
|
||||
|
||||
func _on_claim_response(success: bool, response: Dictionary, error_info: Dictionary, task_id: String, request_generation: int) -> void:
|
||||
if request_generation != _current_account_generation():
|
||||
return
|
||||
if not success:
|
||||
board_failed.emit(str(error_info.get("message", "奖励领取失败")))
|
||||
return
|
||||
var data_variant: Variant = response.get("data", {})
|
||||
if not (data_variant is Dictionary):
|
||||
board_failed.emit("任务奖励响应格式错误")
|
||||
return
|
||||
var data: Dictionary = data_variant as Dictionary
|
||||
var board_variant: Variant = data.get("board", {})
|
||||
if board_variant is Dictionary:
|
||||
_apply_board(board_variant as Dictionary)
|
||||
var wallet_variant: Variant = data.get("wallet", {})
|
||||
if wallet_variant is Dictionary:
|
||||
var wallet: Dictionary = wallet_variant as Dictionary
|
||||
var player_state := get_node_or_null("/root/PlayerStateManager")
|
||||
if player_state != null and player_state.has_method("apply_wallet"):
|
||||
player_state.call("apply_wallet", wallet)
|
||||
reward_claimed.emit(task_id, wallet.duplicate(true))
|
||||
|
||||
func _apply_board_response(response: Dictionary) -> void:
|
||||
var data_variant: Variant = response.get("data", {})
|
||||
if data_variant is Dictionary:
|
||||
_apply_board(data_variant as Dictionary)
|
||||
else:
|
||||
board_failed.emit("任务书响应格式错误")
|
||||
|
||||
func _apply_board(board: Dictionary) -> void:
|
||||
_board = board.duplicate(true)
|
||||
board_changed.emit(get_board())
|
||||
|
||||
func _api_client() -> Node:
|
||||
return get_node_or_null("/root/ApiClient")
|
||||
|
||||
func _is_authenticated() -> bool:
|
||||
var auth_manager := get_node_or_null("/root/AuthManager")
|
||||
return auth_manager != null and auth_manager.has_method("is_authenticated") and bool(auth_manager.call("is_authenticated"))
|
||||
|
||||
func _current_account_generation() -> int:
|
||||
var auth_manager := get_node_or_null("/root/AuthManager")
|
||||
return int(auth_manager.call("get_account_generation")) if auth_manager != null and auth_manager.has_method("get_account_generation") else 0
|
||||
1
_Core/managers/TaskManager.gd.uid
Normal file
1
_Core/managers/TaskManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://byurfu7d8qyfb
|
||||
135
_Core/managers/TestLabManager.gd
Normal file
135
_Core/managers/TestLabManager.gd
Normal file
@@ -0,0 +1,135 @@
|
||||
extends Node
|
||||
|
||||
# 游戏内管理员测试实验室的唯一 API 入口。
|
||||
# 客户端只把 role=9 用于显示入口;实际授权仍由后端 TestLabGuard 完成。
|
||||
|
||||
signal status_changed(status: Dictionary)
|
||||
signal status_failed(message: String)
|
||||
signal operation_completed(message: String)
|
||||
|
||||
var _status: Dictionary = {}
|
||||
var _loading_status: bool = false
|
||||
var _account_generation: int = -1
|
||||
|
||||
func _ready() -> void:
|
||||
var auth_manager := get_node_or_null("/root/AuthManager")
|
||||
if auth_manager != null and auth_manager.has_signal("auth_state_changed"):
|
||||
var callback := Callable(self, "_on_auth_state_changed")
|
||||
if not auth_manager.is_connected("auth_state_changed", callback):
|
||||
auth_manager.connect("auth_state_changed", callback)
|
||||
call_deferred("_refresh_after_ready")
|
||||
|
||||
func get_status() -> Dictionary:
|
||||
return _status.duplicate(true)
|
||||
|
||||
func is_loaded() -> bool:
|
||||
return not _status.is_empty()
|
||||
|
||||
func is_admin() -> bool:
|
||||
var auth_manager := get_node_or_null("/root/AuthManager")
|
||||
if auth_manager == null or not auth_manager.has_method("is_authenticated") or not bool(auth_manager.call("is_authenticated")):
|
||||
return false
|
||||
if not auth_manager.has_method("get_current_user"):
|
||||
return false
|
||||
var user_variant: Variant = auth_manager.call("get_current_user")
|
||||
if not (user_variant is Dictionary):
|
||||
return false
|
||||
var user: Dictionary = user_variant as Dictionary
|
||||
return int(user.get("role", 0)) == 9
|
||||
|
||||
func refresh_status() -> void:
|
||||
if not is_admin() or _loading_status:
|
||||
return
|
||||
var api := _api_client()
|
||||
if api == null:
|
||||
status_failed.emit("测试实验室服务不可用")
|
||||
return
|
||||
_loading_status = true
|
||||
_account_generation = _current_account_generation()
|
||||
api.call("get_json", "/admin/test-lab/status", Callable(self, "_on_status_response").bind(_account_generation), true)
|
||||
|
||||
func create_actor(nickname: String, map_id: String, x: int, y: int, skin_id: String) -> void:
|
||||
_mutate("post_json", "/admin/test-lab/actors", {
|
||||
"nickname": nickname.strip_edges(), "mapId": map_id, "x": x, "y": y, "skinId": skin_id
|
||||
}, "测试假人已上线")
|
||||
|
||||
func update_actor(user_id: String, online: bool, map_id: String, x: int, y: int, skin_id: String) -> void:
|
||||
_mutate("patch_json", "/admin/test-lab/actors/%s" % user_id.uri_encode(), {
|
||||
"online": online, "mapId": map_id, "x": x, "y": y, "skinId": skin_id
|
||||
}, "假人状态已同步")
|
||||
|
||||
func set_room_policy(user_id: String, policy: String) -> void:
|
||||
_mutate("patch_json", "/admin/test-lab/actors/%s/room-policy" % user_id.uri_encode(), {
|
||||
"roomVisitPolicy": policy
|
||||
}, "房间访问策略已更新")
|
||||
|
||||
func send_message(user_id: String, scope: String, content: String, target_user_id: String = "") -> void:
|
||||
var payload := {"scope": scope, "content": content.strip_edges()}
|
||||
if scope == "private":
|
||||
payload["targetUserId"] = target_user_id
|
||||
_mutate("post_json", "/admin/test-lab/actors/%s/messages" % user_id.uri_encode(), payload, "测试消息已发送")
|
||||
|
||||
func social_action(user_id: String, action: String, target_user_id: String) -> void:
|
||||
_mutate("post_json", "/admin/test-lab/actors/%s/social" % user_id.uri_encode(), {
|
||||
"action": action, "targetUserId": target_user_id
|
||||
}, "社交操作已执行")
|
||||
|
||||
func delete_actor(user_id: String) -> void:
|
||||
_mutate("delete_json", "/admin/test-lab/actors/%s" % user_id.uri_encode(), {}, "测试假人已删除")
|
||||
|
||||
func clear_all() -> void:
|
||||
_mutate("delete_json", "/admin/test-lab/actors", {}, "测试实验室已清空")
|
||||
|
||||
func _mutate(method_name: String, endpoint: String, payload: Dictionary, success_message: String) -> void:
|
||||
if not is_admin():
|
||||
status_failed.emit("仅管理员可使用测试实验室")
|
||||
return
|
||||
var api := _api_client()
|
||||
if api == null:
|
||||
status_failed.emit("测试实验室服务不可用")
|
||||
return
|
||||
var request_generation := _current_account_generation()
|
||||
api.call(method_name, endpoint, payload, Callable(self, "_on_mutation_response").bind(success_message, request_generation), true)
|
||||
|
||||
func _refresh_after_ready() -> void:
|
||||
if is_admin():
|
||||
refresh_status()
|
||||
|
||||
func _on_auth_state_changed(is_authenticated: bool, _user: Dictionary) -> void:
|
||||
if not is_authenticated or not is_admin():
|
||||
_status.clear()
|
||||
_loading_status = false
|
||||
_account_generation = -1
|
||||
status_changed.emit({})
|
||||
return
|
||||
refresh_status()
|
||||
|
||||
func _on_status_response(success: bool, response: Dictionary, error_info: Dictionary, request_generation: int) -> void:
|
||||
if request_generation != _current_account_generation():
|
||||
return
|
||||
_loading_status = false
|
||||
if not success:
|
||||
status_failed.emit(str(error_info.get("message", "测试实验室读取失败")))
|
||||
return
|
||||
var data_variant: Variant = response.get("data", {})
|
||||
if not (data_variant is Dictionary):
|
||||
status_failed.emit("测试实验室响应格式错误")
|
||||
return
|
||||
_status = (data_variant as Dictionary).duplicate(true)
|
||||
status_changed.emit(get_status())
|
||||
|
||||
func _on_mutation_response(success: bool, _response: Dictionary, error_info: Dictionary, success_message: String, request_generation: int) -> void:
|
||||
if request_generation != _current_account_generation():
|
||||
return
|
||||
if not success:
|
||||
status_failed.emit(str(error_info.get("message", "测试实验室操作失败")))
|
||||
return
|
||||
operation_completed.emit(success_message)
|
||||
refresh_status()
|
||||
|
||||
func _api_client() -> Node:
|
||||
return get_node_or_null("/root/ApiClient")
|
||||
|
||||
func _current_account_generation() -> int:
|
||||
var auth_manager := get_node_or_null("/root/AuthManager")
|
||||
return int(auth_manager.call("get_account_generation")) if auth_manager != null and auth_manager.has_method("get_account_generation") else 0
|
||||
1
_Core/managers/TestLabManager.gd.uid
Normal file
1
_Core/managers/TestLabManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cntd1faiax3br
|
||||
58
_Core/managers/UiEscapeManager.gd
Normal file
58
_Core/managers/UiEscapeManager.gd
Normal file
@@ -0,0 +1,58 @@
|
||||
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
_Core/managers/UiEscapeManager.gd.uid
Normal file
1
_Core/managers/UiEscapeManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://otx02vxmfni3
|
||||
20
_Core/ui/InteractionPointMarkers.gd
Normal file
20
_Core/ui/InteractionPointMarkers.gd
Normal file
@@ -0,0 +1,20 @@
|
||||
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
_Core/ui/InteractionPointMarkers.gd.uid
Normal file
1
_Core/ui/InteractionPointMarkers.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://de27p7wr4gj2b
|
||||
@@ -21,6 +21,7 @@ config/icon="res://icon.svg"
|
||||
|
||||
[autoload]
|
||||
|
||||
UiEscapeManager="*res://_Core/managers/UiEscapeManager.gd"
|
||||
SceneManager="*res://_Core/managers/SceneManager.gd"
|
||||
EventSystem="*res://_Core/systems/EventSystem.gd"
|
||||
ApiClient="*res://_Core/managers/ApiClient.gd"
|
||||
@@ -32,6 +33,12 @@ CafeCompanionManager="*res://_Core/managers/CafeCompanionManager.gd"
|
||||
AppearanceManager="*res://_Core/managers/AppearanceManager.gd"
|
||||
SettingsManager="*res://_Core/managers/SettingsManager.gd"
|
||||
NotificationSoundManager="*res://_Core/managers/NotificationSoundManager.gd"
|
||||
SocialManager="*res://_Core/managers/SocialManager.gd"
|
||||
InteractionManager="*res://_Core/managers/InteractionManager.gd"
|
||||
TaskManager="*res://_Core/managers/TaskManager.gd"
|
||||
TaskBookPanel="*res://scenes/ui/TaskBookPanel.gd"
|
||||
TestLabManager="*res://_Core/managers/TestLabManager.gd"
|
||||
AdminTestLabPanel="*res://scenes/ui/AdminTestLabPanel.gd"
|
||||
|
||||
[display]
|
||||
|
||||
@@ -71,11 +78,6 @@ interact={
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
friend_request={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":102,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
|
||||
[rendering]
|
||||
|
||||
|
||||
@@ -45,6 +45,22 @@ func _ready() -> void:
|
||||
_connect_exit_area()
|
||||
_connect_recruitment_area()
|
||||
_connect_cafe_companion_events()
|
||||
_register_interactables()
|
||||
_discover_destination()
|
||||
_report_task_map_visit()
|
||||
|
||||
func _report_task_map_visit() -> void:
|
||||
var taskManager := get_node_or_null("/root/TaskManager")
|
||||
if taskManager != null and taskManager.has_method("report_activity"):
|
||||
taskManager.call("report_activity", "map_visited", "whale_cafe")
|
||||
|
||||
func _discover_destination() -> void:
|
||||
var destination_id := SceneManager.get_next_destination_id()
|
||||
if destination_id.is_empty():
|
||||
destination_id = "cafe_entrance"
|
||||
var socialManager := get_node_or_null("/root/SocialManager")
|
||||
if socialManager != null and socialManager.has_method("discover_destination"):
|
||||
socialManager.call("discover_destination", destination_id)
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var eventSystem := get_node_or_null("/root/EventSystem")
|
||||
@@ -56,6 +72,8 @@ func _align_service_occupants() -> void:
|
||||
cafeWhaleBaristaNpc.global_position = serviceIdlePoint01.global_position
|
||||
|
||||
func _apply_spawn_point() -> void:
|
||||
if player.has_scene_position_override:
|
||||
return
|
||||
var spawnName: String = SceneManager.get_next_spawn_name()
|
||||
var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn"
|
||||
var marker := $Markers.get_node_or_null(markerName) as Marker2D
|
||||
@@ -75,8 +93,7 @@ func _configure_camera() -> void:
|
||||
playerCamera.limit_smoothed = true
|
||||
|
||||
func _connect_exit_area() -> void:
|
||||
if not exitToWorkZoneArea.body_entered.is_connected(_on_exit_area_body_entered):
|
||||
exitToWorkZoneArea.body_entered.connect(_on_exit_area_body_entered)
|
||||
exitToWorkZoneArea.collision_mask = 0
|
||||
|
||||
func _connect_recruitment_area() -> void:
|
||||
cafeRecruitmentLogoArea.input_pickable = true
|
||||
@@ -91,8 +108,30 @@ func _connect_cafe_companion_events() -> void:
|
||||
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_AGENT_REGISTERED, _on_cafe_companion_agent_registered, self)
|
||||
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_EMPLOYMENT_RESIGNED, _on_cafe_companion_employment_resigned, self)
|
||||
|
||||
func _register_interactables() -> void:
|
||||
var exit_interactable: InteractableComponent = InteractableComponent.new()
|
||||
exit_interactable.interaction_id = "cafe_exit"
|
||||
exit_interactable.interaction_title = "离开咖啡馆"
|
||||
exit_interactable.interaction_priority = 20
|
||||
exit_interactable.interaction_distance = 160.0
|
||||
exit_interactable.activation_method = &"_leave_to_work_zone"
|
||||
exit_interactable.anchor_path = NodePath("InteractionAreas/ExitToWorkZoneArea")
|
||||
add_child(exit_interactable)
|
||||
|
||||
var recruitment_interactable: InteractableComponent = InteractableComponent.new()
|
||||
recruitment_interactable.interaction_id = "cafe_recruitment"
|
||||
recruitment_interactable.interaction_title = "登记咖啡店陪伴机器人"
|
||||
recruitment_interactable.interaction_priority = 30
|
||||
recruitment_interactable.interaction_distance = 150.0
|
||||
recruitment_interactable.activation_method = &"_try_emit_recruitment_selected"
|
||||
recruitment_interactable.anchor_path = NodePath("InteractionAreas/CafeRecruitmentLogoArea")
|
||||
add_child(recruitment_interactable)
|
||||
|
||||
func _on_exit_area_body_entered(body: Node2D) -> void:
|
||||
if _isChangingScene or body != player:
|
||||
return
|
||||
|
||||
func _leave_to_work_zone() -> void:
|
||||
if _isChangingScene:
|
||||
return
|
||||
_isChangingScene = true
|
||||
SceneManager.set_next_scene_position(WORK_ZONE_CAFE_RETURN_POSITION)
|
||||
|
||||
@@ -56,19 +56,10 @@ func _ready() -> void:
|
||||
call_deferred("_send_world_ready")
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if Input.is_action_just_pressed("interact"):
|
||||
_try_start_private_chat()
|
||||
if Input.is_action_just_pressed("friend_request"):
|
||||
_try_request_friend()
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed("interact"):
|
||||
if _try_start_private_chat():
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
|
||||
if event.is_action_pressed("friend_request") and _try_request_friend():
|
||||
get_viewport().set_input_as_handled()
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
return
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var eventSystem := _get_event_system()
|
||||
@@ -117,7 +108,7 @@ func _is_text_input_focused() -> bool:
|
||||
return false
|
||||
|
||||
func _on_interact_pressed(_data: Dictionary = {}) -> void:
|
||||
_try_start_private_chat()
|
||||
return
|
||||
|
||||
func _try_start_private_chat() -> bool:
|
||||
if _is_text_input_focused():
|
||||
|
||||
@@ -46,6 +46,7 @@ var _inventoryRequestInFlight: bool = false
|
||||
var _inventoryRequestReportsErrors: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("whaletown_escape_dismissible")
|
||||
_leave_multiplayer_world()
|
||||
_apply_spawn_point()
|
||||
_configure_camera()
|
||||
@@ -54,8 +55,17 @@ func _ready() -> void:
|
||||
_build_room_decor_ui()
|
||||
_connect_room_decor_events()
|
||||
_fetch_room_decor_inventory()
|
||||
_discover_destination()
|
||||
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:
|
||||
var chatManager := get_node_or_null("/root/ChatManager")
|
||||
if chatManager != null and chatManager.has_method("leave_world"):
|
||||
@@ -73,6 +83,8 @@ func _exit_tree() -> void:
|
||||
saveManager.decor_save_failed.disconnect(_on_decor_save_failed)
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if get_viewport().is_input_handled():
|
||||
return
|
||||
if event is InputEventMouseButton:
|
||||
var mouseEvent := event as InputEventMouseButton
|
||||
if mouseEvent.button_index != MOUSE_BUTTON_LEFT:
|
||||
@@ -91,7 +103,22 @@ func _process(_delta: float) -> void:
|
||||
if _draggedDecor != null:
|
||||
_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:
|
||||
if player.has_scene_position_override:
|
||||
return
|
||||
var spawnName: String = SceneManager.get_next_spawn_name()
|
||||
var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn"
|
||||
var marker := $Markers.get_node_or_null(markerName) as Marker2D
|
||||
|
||||
@@ -10,14 +10,23 @@ extends Area2D
|
||||
@export var targetSceneName: String = ""
|
||||
@export var targetSpawnName: String = ""
|
||||
@export var targetPosition: Vector2 = Vector2.ZERO
|
||||
@export var interactionTitle: String = "进入"
|
||||
@export var interactionDistance: float = 160.0
|
||||
|
||||
func _ready() -> void:
|
||||
body_entered.connect(_on_body_entered)
|
||||
var label: String = interactionTitle.strip_edges()
|
||||
if label.is_empty():
|
||||
label = "进入 %s" % targetSceneName
|
||||
var interactable: InteractableComponent = InteractableComponent.new()
|
||||
interactable.interaction_id = "portal:%s" % str(get_path())
|
||||
interactable.interaction_title = label
|
||||
interactable.interaction_priority = 20
|
||||
interactable.interaction_distance = interactionDistance
|
||||
interactable.activation_method = &"_change_scene"
|
||||
add_child(interactable)
|
||||
|
||||
func _on_body_entered(body: Node2D) -> void:
|
||||
if not (body is PlayerController):
|
||||
return
|
||||
_change_scene()
|
||||
|
||||
func _change_scene() -> void:
|
||||
if targetSceneName.is_empty():
|
||||
|
||||
@@ -12,6 +12,8 @@ const CAMERA_LIMIT_LEFT: int = -1280
|
||||
const CAMERA_LIMIT_TOP: int = -960
|
||||
const CAMERA_LIMIT_RIGHT: int = 1280
|
||||
const CAMERA_LIMIT_BOTTOM: int = 960
|
||||
const WELCOME_DIALOG_SCENE: PackedScene = preload("res://scenes/ui/welcome_dialog.tscn")
|
||||
const WELCOME_DIALOG_NAME: String = "WelcomeDialog"
|
||||
|
||||
@onready var player: PlayerController = $YSortWorld/Characters/Players/Player
|
||||
@onready var playerCamera: Camera2D = $YSortWorld/Characters/Players/Player/Camera2D
|
||||
@@ -20,8 +22,41 @@ const CAMERA_LIMIT_BOTTOM: int = 960
|
||||
func _ready() -> void:
|
||||
_apply_spawn_point()
|
||||
_configure_camera()
|
||||
_discover_destination()
|
||||
call_deferred("_show_registration_welcome")
|
||||
_report_task_map_visit()
|
||||
|
||||
func _report_task_map_visit() -> void:
|
||||
var taskManager := get_node_or_null("/root/TaskManager")
|
||||
if taskManager != null and taskManager.has_method("report_activity"):
|
||||
taskManager.call("report_activity", "map_visited", "square")
|
||||
|
||||
func _show_registration_welcome() -> void:
|
||||
var auth_manager: Node = get_node_or_null("/root/AuthManager")
|
||||
if auth_manager == null or not auth_manager.has_method("consume_registration_welcome"):
|
||||
return
|
||||
if not bool(auth_manager.call("consume_registration_welcome")):
|
||||
return
|
||||
var root: Window = get_tree().root
|
||||
if root.has_node(WELCOME_DIALOG_NAME):
|
||||
return
|
||||
var dialog: CanvasLayer = WELCOME_DIALOG_SCENE.instantiate() as CanvasLayer
|
||||
if dialog == null:
|
||||
return
|
||||
dialog.name = WELCOME_DIALOG_NAME
|
||||
root.add_child(dialog)
|
||||
|
||||
func _discover_destination() -> void:
|
||||
var destination_id := SceneManager.get_next_destination_id()
|
||||
if destination_id.is_empty():
|
||||
destination_id = "square_center"
|
||||
var socialManager := get_node_or_null("/root/SocialManager")
|
||||
if socialManager != null and socialManager.has_method("discover_destination"):
|
||||
socialManager.call("discover_destination", destination_id)
|
||||
|
||||
func _apply_spawn_point() -> void:
|
||||
if player.has_scene_position_override:
|
||||
return
|
||||
var spawnName: String = SceneManager.get_next_spawn_name()
|
||||
var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn"
|
||||
var marker := $Markers.get_node_or_null(markerName) as Marker2D
|
||||
|
||||
@@ -38,12 +38,29 @@ func _ready() -> void:
|
||||
_ensure_mall_entrance_area()
|
||||
EventSystem.connect_event(EventNames.OBJECT_INTERACTED, _on_object_interacted, self)
|
||||
EventSystem.connect_event(EventNames.MALL_CLOSED, _on_mall_closed, self)
|
||||
_discover_destination()
|
||||
_report_task_map_visit()
|
||||
|
||||
func _report_task_map_visit() -> void:
|
||||
var taskManager := get_node_or_null("/root/TaskManager")
|
||||
if taskManager != null and taskManager.has_method("report_activity"):
|
||||
taskManager.call("report_activity", "map_visited", "work_zone")
|
||||
|
||||
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:
|
||||
EventSystem.disconnect_event(EventNames.OBJECT_INTERACTED, _on_object_interacted, self)
|
||||
EventSystem.disconnect_event(EventNames.MALL_CLOSED, _on_mall_closed, self)
|
||||
|
||||
func _apply_spawn_point() -> void:
|
||||
if player.has_scene_position_override:
|
||||
return
|
||||
var spawnName: String = SceneManager.get_next_spawn_name()
|
||||
var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn"
|
||||
var marker := $Markers.get_node_or_null(markerName) as Marker2D
|
||||
@@ -112,19 +129,23 @@ func _ensure_mall_entrance_area() -> void:
|
||||
|
||||
func _setup_mall_entrance_trigger(entrance: Area2D) -> void:
|
||||
_mallEntranceArea = entrance
|
||||
_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)
|
||||
_mallEntranceArea.collision_mask = 0
|
||||
|
||||
func _on_object_interacted(data: Dictionary) -> void:
|
||||
var buildingId := str(data.get("buildingId", ""))
|
||||
if buildingId.is_empty():
|
||||
return
|
||||
if buildingId == "whale_super_mall":
|
||||
_enter_mall()
|
||||
return
|
||||
if buildingId == "virtual_whale_recruitment_board":
|
||||
_open_course_board()
|
||||
return
|
||||
if buildingId == "whale_job_center":
|
||||
var taskBook := get_node_or_null("/root/TaskBookPanel")
|
||||
if taskBook != null and taskBook.has_method("show_panel"):
|
||||
taskBook.call("show_panel")
|
||||
return
|
||||
if not [
|
||||
"whale_job_center",
|
||||
"ai_service_station",
|
||||
@@ -142,9 +163,7 @@ func _open_course_board() -> void:
|
||||
_courseBoardPanel.call("show_panel")
|
||||
|
||||
func _on_mall_entrance_body_entered(body: Node2D) -> void:
|
||||
if body != player or not _mallCanEnter:
|
||||
return
|
||||
_enter_mall()
|
||||
|
||||
func _enter_mall() -> void:
|
||||
if _isInsideMall:
|
||||
|
||||
@@ -12,10 +12,23 @@ const INTERACTION_COLLISION_LAYER: int = 2
|
||||
@export var buildingId: String = ""
|
||||
@export var buildingTitle: String = ""
|
||||
@export var buildingRole: String = ""
|
||||
@export var interactionDistance: float = 150.0
|
||||
|
||||
func _ready() -> void:
|
||||
collision_layer = INTERACTION_COLLISION_LAYER
|
||||
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:
|
||||
var payload := {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=12 format=4]
|
||||
[gd_scene load_steps=13 format=4]
|
||||
|
||||
[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"]
|
||||
@@ -8,6 +8,7 @@
|
||||
[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/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"]
|
||||
size = Vector2(882, 76)
|
||||
@@ -35,6 +36,8 @@ layer = 10
|
||||
|
||||
[node name="SettingsPanel" parent="UILayer" instance=ExtResource("8_settingspanel")]
|
||||
|
||||
[node name="MapPanel" parent="UILayer" instance=ExtResource("9_mappanel")]
|
||||
|
||||
[node name="StageBackdrop" type="ColorRect" parent="."]
|
||||
z_index = -200
|
||||
offset_left = -2000.0
|
||||
|
||||
@@ -117,9 +117,6 @@ size = Vector2(180, 160)
|
||||
[sub_resource type="RectangleShape2D" id="Shape_NoticeBoard"]
|
||||
size = Vector2(123, 88)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="Shape_WelcomeBoard"]
|
||||
size = Vector2(97.5, 95)
|
||||
|
||||
[node name="Square" type="Node2D" unique_id=1071968945]
|
||||
script = ExtResource("43_square")
|
||||
|
||||
@@ -1237,15 +1234,6 @@ script = ExtResource("28_notice")
|
||||
position = Vector2(-59.5, 19)
|
||||
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]
|
||||
visible = false
|
||||
|
||||
|
||||
@@ -19,9 +19,22 @@ class_name CafeCompanionTarget
|
||||
var _lastClickMsec: int = 0
|
||||
|
||||
func _ready() -> void:
|
||||
var interactable: InteractableComponent = InteractableComponent.new()
|
||||
interactable.interaction_distance = 160.0
|
||||
add_child(interactable)
|
||||
input_pickable = 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:
|
||||
if not (event is InputEventMouseButton):
|
||||
return
|
||||
|
||||
@@ -34,12 +34,21 @@ const NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12
|
||||
@export_multiline var dialogue: String = "欢迎来到WhaleTown,我是镇长范鲸晶"
|
||||
@export var showNameplate: bool = false
|
||||
@export var nameplateOffsetY: float = -112.0
|
||||
@export var interactionDistance: float = 150.0
|
||||
|
||||
@onready var animation_player: AnimationPlayer = $AnimationPlayer
|
||||
|
||||
var _nameplate: Label
|
||||
|
||||
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 可以复用同一个控制器。
|
||||
if animation_player.has_animation("idle"):
|
||||
animation_player.play("idle")
|
||||
@@ -55,6 +64,9 @@ func _physics_process(_delta: float) -> void:
|
||||
|
||||
# 处理玩家交互,展示气泡并向全局事件系统广播。
|
||||
func interact() -> void:
|
||||
var taskManager := get_node_or_null("/root/TaskManager")
|
||||
if taskManager != null and taskManager.has_method("report_activity"):
|
||||
taskManager.call("report_activity", "facility_interacted", "npc")
|
||||
show_bubble(dialogue)
|
||||
var eventSystem: Node = get_node_or_null("/root/EventSystem")
|
||||
if eventSystem != null:
|
||||
|
||||
@@ -25,8 +25,11 @@ const DIRECTION_ROWS: Dictionary = {
|
||||
var lastDirection: String = "down"
|
||||
var _nameLabel: Label
|
||||
var _movementLocked: bool = false
|
||||
var has_scene_position_override: bool = false
|
||||
var _discoveryElapsed: float = 0.0
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("whaletown_local_player")
|
||||
_reset_movement_input_state()
|
||||
_apply_current_appearance()
|
||||
_subscribe_to_appearance_events()
|
||||
@@ -71,28 +74,40 @@ func _release_movement_actions() -> void:
|
||||
|
||||
func _check_spawn_position() -> void:
|
||||
var spawnPos: Variant = SceneManager.get_next_scene_position()
|
||||
if spawnPos != null:
|
||||
if spawnPos is Vector2:
|
||||
global_position = spawnPos
|
||||
has_scene_position_override = true
|
||||
_update_world_sort_z()
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_handle_movement(delta)
|
||||
_update_world_sort_z()
|
||||
_handle_interaction()
|
||||
_report_nearby_discoveries(delta)
|
||||
|
||||
func _handle_interaction() -> void:
|
||||
if _is_text_input_focused():
|
||||
# 统一交互由 /root/InteractionManager 收集附近候选并处理 E 键。
|
||||
return
|
||||
if Input.is_action_just_pressed("interact"):
|
||||
EventSystem.emit_event(EventNames.INTERACT_PRESSED, {
|
||||
"player": self,
|
||||
"position": global_position,
|
||||
"direction": lastDirection
|
||||
})
|
||||
if ray_cast.is_colliding():
|
||||
var collider := ray_cast.get_collider()
|
||||
if collider and collider.has_method("interact"):
|
||||
collider.interact()
|
||||
|
||||
func _report_nearby_discoveries(delta: float) -> void:
|
||||
_discoveryElapsed += delta
|
||||
if _discoveryElapsed < 0.35:
|
||||
return
|
||||
_discoveryElapsed = 0.0
|
||||
var scene := get_tree().current_scene
|
||||
if scene == null:
|
||||
return
|
||||
var map_id: String = str({
|
||||
"Square": "whale_port",
|
||||
"WorkZone": "work_zone",
|
||||
"CafeInterior": "whale_cafe",
|
||||
"PersonalSpace": "personal_space",
|
||||
}.get(str(scene.name), ""))
|
||||
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:
|
||||
if _movementLocked:
|
||||
@@ -111,10 +126,7 @@ func _handle_movement(_delta: float) -> void:
|
||||
return
|
||||
|
||||
# 获取移动向量 (参考 docs/02-开发规范/输入映射配置.md)
|
||||
var direction := Input.get_vector(
|
||||
"move_left", "move_right",
|
||||
"move_up", "move_down"
|
||||
)
|
||||
var direction := _movement_direction()
|
||||
|
||||
# 应用移动
|
||||
if direction != Vector2.ZERO:
|
||||
@@ -133,6 +145,16 @@ func _handle_movement(_delta: float) -> void:
|
||||
"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:
|
||||
if not animation_player:
|
||||
return
|
||||
|
||||
@@ -36,8 +36,12 @@ const DIRECTION_ROWS: Dictionary = {
|
||||
@onready var sprite: Sprite2D = $Sprite2D
|
||||
var _nameLabel: Label
|
||||
var _cafeCompanionTarget: CafeCompanionTarget
|
||||
var _interactable: InteractableComponent
|
||||
|
||||
func _ready() -> void:
|
||||
_interactable = InteractableComponent.new()
|
||||
_interactable.interaction_distance = 160.0
|
||||
add_child(_interactable)
|
||||
# 初始化时确保无物理处理
|
||||
set_physics_process(false)
|
||||
# 初始位置设为当前位置
|
||||
@@ -52,6 +56,32 @@ func _ready() -> void:
|
||||
if has_node("CollisionShape2D"):
|
||||
$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:
|
||||
var eventSystem := get_node_or_null("/root/EventSystem")
|
||||
if eventSystem != null:
|
||||
|
||||
@@ -8,6 +8,13 @@ const INTERACTION_COLLISION_LAYER: int = 2
|
||||
func _ready() -> void:
|
||||
collision_layer = INTERACTION_COLLISION_LAYER
|
||||
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:
|
||||
var root: Window = get_tree().root
|
||||
|
||||
@@ -8,8 +8,19 @@ const INTERACTION_COLLISION_LAYER: int = 2
|
||||
func _ready() -> void:
|
||||
collision_layer = INTERACTION_COLLISION_LAYER
|
||||
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:
|
||||
var taskManager := get_node_or_null("/root/TaskManager")
|
||||
if taskManager != null and taskManager.has_method("report_activity"):
|
||||
taskManager.call("report_activity", "notice_viewed")
|
||||
taskManager.call("report_activity", "facility_interacted", "notice_board")
|
||||
var root: Window = get_tree().root
|
||||
if root.has_node(NOTICE_DIALOG_NAME):
|
||||
return
|
||||
|
||||
@@ -8,8 +8,18 @@ const INTERACTION_COLLISION_LAYER: int = 2
|
||||
func _ready() -> void:
|
||||
collision_layer = INTERACTION_COLLISION_LAYER
|
||||
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:
|
||||
var taskManager := get_node_or_null("/root/TaskManager")
|
||||
if taskManager != null and taskManager.has_method("report_activity"):
|
||||
taskManager.call("report_activity", "facility_interacted", "welcome_board")
|
||||
var root: Window = get_tree().root
|
||||
if root.has_node(WELCOME_DIALOG_NAME):
|
||||
return
|
||||
|
||||
685
scenes/ui/AdminTestLabPanel.gd
Normal file
685
scenes/ui/AdminTestLabPanel.gd
Normal file
@@ -0,0 +1,685 @@
|
||||
extends CanvasLayer
|
||||
|
||||
# 仅 role=9 可打开的游戏内测试实验室。服务端会再次验证管理员身份。
|
||||
|
||||
const PANEL_SIZE: Vector2 = Vector2(1180, 820)
|
||||
const MAP_OPTIONS: Array[Dictionary] = [
|
||||
{"id": "whale_port", "label": "鲸鱼港广场"},
|
||||
{"id": "work_zone", "label": "打工区"},
|
||||
{"id": "whale_cafe", "label": "鲸鱼咖啡馆"},
|
||||
]
|
||||
const SKIN_OPTIONS: Array[Dictionary] = [
|
||||
{"id": "classic_whale", "label": "经典鲸鱼"},
|
||||
{"id": "human_whale_directional_v2_8x4", "label": "海风行者"},
|
||||
{"id": "girl_sailor_turnaround_v2_8x4", "label": "海风少女"},
|
||||
{"id": "panda_hero_8x4", "label": "熊猫侠"},
|
||||
{"id": "ordinary_man_male_8x4", "label": "普通人(男)"},
|
||||
]
|
||||
const POLICY_OPTIONS: Array[Dictionary] = [
|
||||
{"id": "public", "label": "公开访问"},
|
||||
{"id": "friends", "label": "仅好友"},
|
||||
{"id": "closed", "label": "关闭访问"},
|
||||
]
|
||||
const SCOPE_OPTIONS: Array[Dictionary] = [
|
||||
{"id": "local", "label": "当前地图公共消息"},
|
||||
{"id": "global", "label": "全服测试消息"},
|
||||
{"id": "private", "label": "私聊选定玩家"},
|
||||
]
|
||||
const TEXT_COLOR: Color = Color(0.84, 0.92, 0.96)
|
||||
const MUTED_COLOR: Color = Color(0.53, 0.68, 0.75)
|
||||
const SURFACE_COLOR: Color = Color(0.035, 0.105, 0.16, 0.98)
|
||||
const RAISED_COLOR: Color = Color(0.065, 0.16, 0.23, 1.0)
|
||||
const ACCENT_COLOR: Color = Color(0.20, 0.80, 0.86, 1.0)
|
||||
const DANGER_COLOR: Color = Color(0.82, 0.29, 0.25, 1.0)
|
||||
|
||||
var _root: Control
|
||||
var _panel: PanelContainer
|
||||
var _status_label: Label
|
||||
var _notice_label: Label
|
||||
var _actor_list: VBoxContainer
|
||||
var _selected_label: Label
|
||||
var _target_select: OptionButton
|
||||
var _create_nickname: LineEdit
|
||||
var _create_map: OptionButton
|
||||
var _create_x: SpinBox
|
||||
var _create_y: SpinBox
|
||||
var _create_skin: OptionButton
|
||||
var _actor_online: CheckButton
|
||||
var _actor_map: OptionButton
|
||||
var _actor_x: SpinBox
|
||||
var _actor_y: SpinBox
|
||||
var _actor_skin: OptionButton
|
||||
var _policy_select: OptionButton
|
||||
var _message_scope: OptionButton
|
||||
var _message_input: TextEdit
|
||||
var _selected_actor_id: String = ""
|
||||
var _is_open: bool = false
|
||||
var _clear_confirm_deadline_ms: int = 0
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 48
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
add_to_group("whaletown_escape_dismissible")
|
||||
_build_ui()
|
||||
_connect_services()
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var event_system := get_node_or_null("/root/EventSystem")
|
||||
if event_system != null:
|
||||
event_system.call("disconnect_event", EventNames.HUD_ADMIN_TEST_LAB_REQUESTED, _on_open_requested, self)
|
||||
var manager := get_node_or_null("/root/TestLabManager")
|
||||
if manager != null:
|
||||
_disconnect_signal(manager, "status_changed", Callable(self, "_on_status_changed"))
|
||||
_disconnect_signal(manager, "status_failed", Callable(self, "_on_status_failed"))
|
||||
_disconnect_signal(manager, "operation_completed", Callable(self, "_on_operation_completed"))
|
||||
|
||||
func show_panel() -> void:
|
||||
if not _is_admin():
|
||||
hide_panel()
|
||||
return
|
||||
_is_open = true
|
||||
_root.visible = true
|
||||
_set_notice("正在读取测试实验室…", MUTED_COLOR)
|
||||
var manager := _manager()
|
||||
if manager != null:
|
||||
if manager.has_method("is_loaded") and bool(manager.call("is_loaded")):
|
||||
var status_variant: Variant = manager.call("get_status")
|
||||
if status_variant is Dictionary:
|
||||
_on_status_changed(status_variant as Dictionary)
|
||||
if manager.has_method("refresh_status"):
|
||||
manager.call("refresh_status")
|
||||
|
||||
func hide_panel() -> void:
|
||||
_is_open = false
|
||||
if is_instance_valid(_root):
|
||||
_root.visible = false
|
||||
|
||||
func is_escape_dismissible() -> bool:
|
||||
return _is_open
|
||||
|
||||
func get_escape_priority() -> int:
|
||||
return 850
|
||||
|
||||
func request_escape_close() -> void:
|
||||
hide_panel()
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if not _is_open or get_viewport().is_input_handled():
|
||||
return
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
hide_panel()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
func _connect_services() -> void:
|
||||
var event_system := get_node_or_null("/root/EventSystem")
|
||||
if event_system != null:
|
||||
event_system.call("connect_event", EventNames.HUD_ADMIN_TEST_LAB_REQUESTED, _on_open_requested, self)
|
||||
var manager := _manager()
|
||||
if manager == null:
|
||||
return
|
||||
_connect_signal(manager, "status_changed", Callable(self, "_on_status_changed"))
|
||||
_connect_signal(manager, "status_failed", Callable(self, "_on_status_failed"))
|
||||
_connect_signal(manager, "operation_completed", Callable(self, "_on_operation_completed"))
|
||||
|
||||
func _build_ui() -> void:
|
||||
_root = Control.new()
|
||||
_root.name = "AdminTestLabRoot"
|
||||
_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_root.visible = false
|
||||
add_child(_root)
|
||||
|
||||
var dim := ColorRect.new()
|
||||
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
dim.color = Color(0.005, 0.02, 0.04, 0.78)
|
||||
dim.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
dim.gui_input.connect(func(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton and (event as InputEventMouseButton).pressed:
|
||||
hide_panel()
|
||||
)
|
||||
_root.add_child(dim)
|
||||
|
||||
_panel = PanelContainer.new()
|
||||
_panel.custom_minimum_size = PANEL_SIZE
|
||||
_panel.set_anchors_preset(Control.PRESET_CENTER)
|
||||
_panel.offset_left = -PANEL_SIZE.x * 0.5
|
||||
_panel.offset_top = -PANEL_SIZE.y * 0.5
|
||||
_panel.offset_right = PANEL_SIZE.x * 0.5
|
||||
_panel.offset_bottom = PANEL_SIZE.y * 0.5
|
||||
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_panel.add_theme_stylebox_override("panel", _style(SURFACE_COLOR, 24, Color(0.18, 0.48, 0.58, 0.86), 1))
|
||||
_root.add_child(_panel)
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 24)
|
||||
margin.add_theme_constant_override("margin_top", 20)
|
||||
margin.add_theme_constant_override("margin_right", 24)
|
||||
margin.add_theme_constant_override("margin_bottom", 20)
|
||||
_panel.add_child(margin)
|
||||
var layout := VBoxContainer.new()
|
||||
layout.add_theme_constant_override("separation", 14)
|
||||
margin.add_child(layout)
|
||||
layout.add_child(_build_header())
|
||||
_notice_label = Label.new()
|
||||
_notice_label.visible = false
|
||||
_notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_notice_label.add_theme_font_size_override("font_size", 14)
|
||||
layout.add_child(_notice_label)
|
||||
var body := HSplitContainer.new()
|
||||
body.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
body.split_offset = 358
|
||||
layout.add_child(body)
|
||||
body.add_child(_build_roster_column())
|
||||
body.add_child(_build_control_column())
|
||||
var footer := HBoxContainer.new()
|
||||
footer.add_theme_constant_override("separation", 10)
|
||||
layout.add_child(footer)
|
||||
var hint := Label.new()
|
||||
hint.text = "仅开发/测试环境可用;所有动作由服务端再次校验。"
|
||||
hint.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
hint.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
hint.add_theme_font_size_override("font_size", 13)
|
||||
footer.add_child(hint)
|
||||
footer.add_child(_button("刷新", ACCENT_COLOR, _on_refresh_pressed, Vector2(86, 38)))
|
||||
footer.add_child(_button("清空实验室", DANGER_COLOR, _on_clear_pressed, Vector2(126, 38)))
|
||||
var esc := Label.new()
|
||||
esc.text = "[Esc] 关闭"
|
||||
esc.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
esc.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
esc.add_theme_font_size_override("font_size", 13)
|
||||
footer.add_child(esc)
|
||||
|
||||
func _build_header() -> Control:
|
||||
var header := PanelContainer.new()
|
||||
header.custom_minimum_size = Vector2(0, 92)
|
||||
header.add_theme_stylebox_override("panel", _style(Color(0.045, 0.19, 0.27, 1.0), 16, ACCENT_COLOR.darkened(0.30), 1))
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 20)
|
||||
margin.add_theme_constant_override("margin_top", 12)
|
||||
margin.add_theme_constant_override("margin_right", 14)
|
||||
margin.add_theme_constant_override("margin_bottom", 12)
|
||||
header.add_child(margin)
|
||||
var row := HBoxContainer.new()
|
||||
row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
margin.add_child(row)
|
||||
var title_box := VBoxContainer.new()
|
||||
title_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
row.add_child(title_box)
|
||||
var eyebrow := Label.new()
|
||||
eyebrow.text = "管理员 · 多人互动验证"
|
||||
eyebrow.add_theme_color_override("font_color", Color(0.51, 0.86, 0.89, 1.0))
|
||||
eyebrow.add_theme_font_size_override("font_size", 13)
|
||||
title_box.add_child(eyebrow)
|
||||
var title := Label.new()
|
||||
title.text = "测试实验室"
|
||||
title.add_theme_color_override("font_color", Color.WHITE)
|
||||
title.add_theme_font_size_override("font_size", 30)
|
||||
title_box.add_child(title)
|
||||
_status_label = Label.new()
|
||||
_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
_status_label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
_status_label.add_theme_font_size_override("font_size", 14)
|
||||
row.add_child(_status_label)
|
||||
row.add_child(_button("关闭", Color(0.20, 0.36, 0.44, 1.0), hide_panel, Vector2(78, 38)))
|
||||
return header
|
||||
|
||||
func _build_roster_column() -> Control:
|
||||
var column := VBoxContainer.new()
|
||||
column.custom_minimum_size = Vector2(336, 0)
|
||||
column.add_theme_constant_override("separation", 12)
|
||||
var create_title := Label.new()
|
||||
create_title.text = "投放假人"
|
||||
create_title.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
create_title.add_theme_font_size_override("font_size", 20)
|
||||
column.add_child(create_title)
|
||||
var create_box := PanelContainer.new()
|
||||
create_box.add_theme_stylebox_override("panel", _style(RAISED_COLOR, 14, Color(0.16, 0.36, 0.46, 0.72), 1))
|
||||
column.add_child(create_box)
|
||||
var create_margin := MarginContainer.new()
|
||||
create_margin.add_theme_constant_override("margin_left", 14)
|
||||
create_margin.add_theme_constant_override("margin_top", 14)
|
||||
create_margin.add_theme_constant_override("margin_right", 14)
|
||||
create_margin.add_theme_constant_override("margin_bottom", 14)
|
||||
create_box.add_child(create_margin)
|
||||
var create_form := VBoxContainer.new()
|
||||
create_form.add_theme_constant_override("separation", 8)
|
||||
create_margin.add_child(create_form)
|
||||
_create_nickname = _line_edit("显示昵称,例如 社交测试鲸")
|
||||
create_form.add_child(_field("昵称", _create_nickname))
|
||||
_create_map = _option(MAP_OPTIONS)
|
||||
create_form.add_child(_field("地图", _create_map))
|
||||
var create_position := HBoxContainer.new()
|
||||
create_position.add_theme_constant_override("separation", 8)
|
||||
_create_x = _spin(1280)
|
||||
_create_y = _spin(960)
|
||||
create_position.add_child(_field("X", _create_x))
|
||||
create_position.add_child(_field("Y", _create_y))
|
||||
create_form.add_child(create_position)
|
||||
_create_skin = _option(SKIN_OPTIONS)
|
||||
create_form.add_child(_field("皮肤", _create_skin))
|
||||
create_form.add_child(_button("创建并上线", ACCENT_COLOR, _on_create_pressed, Vector2(0, 40)))
|
||||
var roster_title := Label.new()
|
||||
roster_title.text = "假人编队"
|
||||
roster_title.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
roster_title.add_theme_font_size_override("font_size", 20)
|
||||
column.add_child(roster_title)
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
column.add_child(scroll)
|
||||
_actor_list = VBoxContainer.new()
|
||||
_actor_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_actor_list.add_theme_constant_override("separation", 7)
|
||||
scroll.add_child(_actor_list)
|
||||
return column
|
||||
|
||||
func _build_control_column() -> Control:
|
||||
var column := VBoxContainer.new()
|
||||
column.add_theme_constant_override("separation", 12)
|
||||
var title_row := HBoxContainer.new()
|
||||
column.add_child(title_row)
|
||||
_selected_label = Label.new()
|
||||
_selected_label.text = "选择一名假人开始控制"
|
||||
_selected_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_selected_label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
_selected_label.add_theme_font_size_override("font_size", 20)
|
||||
title_row.add_child(_selected_label)
|
||||
title_row.add_child(_button("删除假人", DANGER_COLOR, _on_delete_selected_pressed, Vector2(96, 34)))
|
||||
var controls_scroll := ScrollContainer.new()
|
||||
controls_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
controls_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
column.add_child(controls_scroll)
|
||||
var controls := VBoxContainer.new()
|
||||
controls.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
controls.add_theme_constant_override("separation", 12)
|
||||
controls_scroll.add_child(controls)
|
||||
controls.add_child(_build_presence_section())
|
||||
controls.add_child(_build_room_section())
|
||||
controls.add_child(_build_social_section())
|
||||
return column
|
||||
|
||||
func _build_presence_section() -> Control:
|
||||
var section := _section("在线与位置")
|
||||
var content := section.get_node("Margin/Layout/Content") as VBoxContainer
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 10)
|
||||
_actor_online = CheckButton.new()
|
||||
_actor_online.text = "在线"
|
||||
_actor_online.button_pressed = true
|
||||
_actor_online.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
row.add_child(_actor_online)
|
||||
row.add_spacer(false)
|
||||
row.add_child(_button("同步状态", ACCENT_COLOR, _on_update_pressed, Vector2(104, 36)))
|
||||
content.add_child(row)
|
||||
_actor_map = _option(MAP_OPTIONS)
|
||||
content.add_child(_field("地图", _actor_map))
|
||||
var position := HBoxContainer.new()
|
||||
position.add_theme_constant_override("separation", 10)
|
||||
_actor_x = _spin(1280)
|
||||
_actor_y = _spin(960)
|
||||
position.add_child(_field("X", _actor_x))
|
||||
position.add_child(_field("Y", _actor_y))
|
||||
content.add_child(position)
|
||||
_actor_skin = _option(SKIN_OPTIONS)
|
||||
content.add_child(_field("皮肤", _actor_skin))
|
||||
return section
|
||||
|
||||
func _build_room_section() -> Control:
|
||||
var section := _section("房间访问")
|
||||
var content := section.get_node("Margin/Layout/Content") as VBoxContainer
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 10)
|
||||
_policy_select = _option(POLICY_OPTIONS)
|
||||
_policy_select.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
row.add_child(_policy_select)
|
||||
row.add_child(_button("保存权限", Color(0.20, 0.42, 0.55, 1.0), _on_policy_pressed, Vector2(102, 36)))
|
||||
content.add_child(row)
|
||||
return section
|
||||
|
||||
func _build_social_section() -> Control:
|
||||
var section := _section("社交注入")
|
||||
var content := section.get_node("Margin/Layout/Content") as VBoxContainer
|
||||
var target_hint := Label.new()
|
||||
target_hint.text = "目标真实在线玩家(私聊、好友和拉黑操作必选)"
|
||||
target_hint.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
target_hint.add_theme_font_size_override("font_size", 13)
|
||||
content.add_child(target_hint)
|
||||
_target_select = OptionButton.new()
|
||||
_target_select.custom_minimum_size = Vector2(0, 36)
|
||||
_target_select.add_item("暂无在线真实玩家")
|
||||
_target_select.add_theme_stylebox_override("normal", _style(Color(0.03, 0.10, 0.15, 1.0), 10, Color(0.20, 0.42, 0.52, 0.8), 1))
|
||||
_target_select.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
content.add_child(_target_select)
|
||||
_message_scope = _option(SCOPE_OPTIONS)
|
||||
content.add_child(_field("消息范围", _message_scope))
|
||||
_message_input = TextEdit.new()
|
||||
_message_input.custom_minimum_size = Vector2(0, 78)
|
||||
_message_input.placeholder_text = "测试消息不会同步到 Zulip、任务或鲸币。"
|
||||
_message_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
|
||||
_message_input.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
_message_input.add_theme_color_override("font_placeholder_color", MUTED_COLOR)
|
||||
_message_input.add_theme_stylebox_override("normal", _style(Color(0.025, 0.085, 0.13, 1.0), 10, Color(0.20, 0.42, 0.52, 0.8), 1))
|
||||
content.add_child(_message_input)
|
||||
content.add_child(_button("发送测试消息", ACCENT_COLOR, _on_message_pressed, Vector2(0, 38)))
|
||||
var social_row := HBoxContainer.new()
|
||||
social_row.add_theme_constant_override("separation", 8)
|
||||
social_row.add_child(_button("发起好友申请", Color(0.20, 0.42, 0.55, 1.0), func() -> void: _on_social_pressed("friend_request"), Vector2(116, 36)))
|
||||
social_row.add_child(_button("接受", Color(0.20, 0.42, 0.55, 1.0), func() -> void: _on_social_pressed("friend_accept"), Vector2(72, 36)))
|
||||
social_row.add_child(_button("拒绝", Color(0.20, 0.42, 0.55, 1.0), func() -> void: _on_social_pressed("friend_reject"), Vector2(72, 36)))
|
||||
social_row.add_child(_button("拉黑", DANGER_COLOR, func() -> void: _on_social_pressed("block"), Vector2(72, 36)))
|
||||
content.add_child(social_row)
|
||||
return section
|
||||
|
||||
func _section(title_text: String) -> PanelContainer:
|
||||
var section := PanelContainer.new()
|
||||
section.add_theme_stylebox_override("panel", _style(RAISED_COLOR, 14, Color(0.16, 0.36, 0.46, 0.72), 1))
|
||||
var margin := MarginContainer.new()
|
||||
margin.name = "Margin"
|
||||
margin.add_theme_constant_override("margin_left", 14)
|
||||
margin.add_theme_constant_override("margin_top", 12)
|
||||
margin.add_theme_constant_override("margin_right", 14)
|
||||
margin.add_theme_constant_override("margin_bottom", 14)
|
||||
section.add_child(margin)
|
||||
var layout := VBoxContainer.new()
|
||||
layout.name = "Layout"
|
||||
layout.add_theme_constant_override("separation", 9)
|
||||
margin.add_child(layout)
|
||||
var title := Label.new()
|
||||
title.text = title_text
|
||||
title.add_theme_color_override("font_color", Color(0.68, 0.91, 0.94, 1.0))
|
||||
title.add_theme_font_size_override("font_size", 16)
|
||||
layout.add_child(title)
|
||||
var content := VBoxContainer.new()
|
||||
content.name = "Content"
|
||||
content.add_theme_constant_override("separation", 7)
|
||||
layout.add_child(content)
|
||||
return section
|
||||
|
||||
func _field(label_text: String, control: Control) -> VBoxContainer:
|
||||
var field := VBoxContainer.new()
|
||||
field.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
field.add_theme_constant_override("separation", 3)
|
||||
var label := Label.new()
|
||||
label.text = label_text
|
||||
label.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
label.add_theme_font_size_override("font_size", 12)
|
||||
field.add_child(label)
|
||||
control.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
field.add_child(control)
|
||||
return field
|
||||
|
||||
func _line_edit(placeholder: String) -> LineEdit:
|
||||
var input := LineEdit.new()
|
||||
input.placeholder_text = placeholder
|
||||
input.custom_minimum_size = Vector2(0, 36)
|
||||
input.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
input.add_theme_color_override("font_placeholder_color", MUTED_COLOR)
|
||||
input.add_theme_stylebox_override("normal", _style(Color(0.025, 0.085, 0.13, 1.0), 10, Color(0.20, 0.42, 0.52, 0.8), 1))
|
||||
input.add_theme_stylebox_override("focus", _style(Color(0.025, 0.085, 0.13, 1.0), 10, ACCENT_COLOR, 1))
|
||||
return input
|
||||
|
||||
func _spin(initial_value: int) -> SpinBox:
|
||||
var spin := SpinBox.new()
|
||||
spin.min_value = -8192
|
||||
spin.max_value = 8192
|
||||
spin.step = 1
|
||||
spin.value = initial_value
|
||||
spin.custom_minimum_size = Vector2(0, 36)
|
||||
spin.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
return spin
|
||||
|
||||
func _option(items: Array[Dictionary]) -> OptionButton:
|
||||
var select := OptionButton.new()
|
||||
select.custom_minimum_size = Vector2(0, 36)
|
||||
select.add_theme_stylebox_override("normal", _style(Color(0.025, 0.085, 0.13, 1.0), 10, Color(0.20, 0.42, 0.52, 0.8), 1))
|
||||
select.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
for item: Dictionary in items:
|
||||
select.add_item(str(item.get("label", "")))
|
||||
select.set_item_metadata(select.item_count - 1, str(item.get("id", "")))
|
||||
return select
|
||||
|
||||
func _button(text_value: String, color: Color, callback: Callable, size: Vector2) -> Button:
|
||||
var button := Button.new()
|
||||
button.text = text_value
|
||||
button.custom_minimum_size = size
|
||||
button.focus_mode = Control.FOCUS_NONE
|
||||
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
button.add_theme_color_override("font_color", Color.WHITE)
|
||||
button.add_theme_color_override("font_hover_color", Color.WHITE)
|
||||
button.add_theme_stylebox_override("normal", _style(color, 10))
|
||||
button.add_theme_stylebox_override("hover", _style(color.lightened(0.12), 10))
|
||||
button.add_theme_stylebox_override("pressed", _style(color.darkened(0.12), 10))
|
||||
button.pressed.connect(callback)
|
||||
return button
|
||||
|
||||
func _style(color: Color, radius: int, border_color: Color = Color.TRANSPARENT, border_width: int = 0) -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = color
|
||||
style.corner_radius_top_left = radius
|
||||
style.corner_radius_top_right = radius
|
||||
style.corner_radius_bottom_left = radius
|
||||
style.corner_radius_bottom_right = radius
|
||||
if border_width > 0:
|
||||
style.border_width_left = border_width
|
||||
style.border_width_top = border_width
|
||||
style.border_width_right = border_width
|
||||
style.border_width_bottom = border_width
|
||||
style.border_color = border_color
|
||||
return style
|
||||
|
||||
func _on_open_requested(_data: Variant = null) -> void:
|
||||
show_panel()
|
||||
|
||||
func _on_refresh_pressed() -> void:
|
||||
var manager := _manager()
|
||||
if manager != null:
|
||||
manager.call("refresh_status")
|
||||
|
||||
func _on_create_pressed() -> void:
|
||||
if _create_nickname.text.strip_edges().is_empty():
|
||||
_set_notice("请输入测试假人的显示昵称", DANGER_COLOR.lightened(0.18))
|
||||
return
|
||||
var manager := _manager()
|
||||
if manager != null:
|
||||
manager.call("create_actor", _create_nickname.text, _selected_option(_create_map), int(_create_x.value), int(_create_y.value), _selected_option(_create_skin))
|
||||
|
||||
func _on_update_pressed() -> void:
|
||||
if not _ensure_selected_actor():
|
||||
return
|
||||
var manager := _manager()
|
||||
if manager != null:
|
||||
manager.call("update_actor", _selected_actor_id, _actor_online.button_pressed, _selected_option(_actor_map), int(_actor_x.value), int(_actor_y.value), _selected_option(_actor_skin))
|
||||
|
||||
func _on_policy_pressed() -> void:
|
||||
if not _ensure_selected_actor():
|
||||
return
|
||||
var manager := _manager()
|
||||
if manager != null:
|
||||
manager.call("set_room_policy", _selected_actor_id, _selected_option(_policy_select))
|
||||
|
||||
func _on_message_pressed() -> void:
|
||||
if not _ensure_selected_actor():
|
||||
return
|
||||
var content := _message_input.text.strip_edges()
|
||||
if content.is_empty():
|
||||
_set_notice("请输入测试消息", DANGER_COLOR.lightened(0.18))
|
||||
return
|
||||
var scope := _selected_option(_message_scope)
|
||||
var target_id := _selected_target_id()
|
||||
if scope == "private" and target_id.is_empty():
|
||||
_set_notice("私聊需要先选择在线真实玩家", DANGER_COLOR.lightened(0.18))
|
||||
return
|
||||
var manager := _manager()
|
||||
if manager != null:
|
||||
manager.call("send_message", _selected_actor_id, scope, content, target_id)
|
||||
|
||||
func _on_social_pressed(action: String) -> void:
|
||||
if not _ensure_selected_actor():
|
||||
return
|
||||
var target_id := _selected_target_id()
|
||||
if target_id.is_empty():
|
||||
_set_notice("请先选择在线真实玩家", DANGER_COLOR.lightened(0.18))
|
||||
return
|
||||
var manager := _manager()
|
||||
if manager != null:
|
||||
manager.call("social_action", _selected_actor_id, action, target_id)
|
||||
|
||||
func _on_clear_pressed() -> void:
|
||||
var now_ms: int = Time.get_ticks_msec()
|
||||
if now_ms > _clear_confirm_deadline_ms:
|
||||
_clear_confirm_deadline_ms = now_ms + 4000
|
||||
_set_notice("再次点击“清空实验室”以确认;这会删除全部测试账号和关联测试数据。", DANGER_COLOR.lightened(0.18))
|
||||
return
|
||||
_clear_confirm_deadline_ms = 0
|
||||
var manager := _manager()
|
||||
if manager != null:
|
||||
manager.call("clear_all")
|
||||
|
||||
func _on_delete_selected_pressed() -> void:
|
||||
if not _ensure_selected_actor():
|
||||
return
|
||||
var manager := _manager()
|
||||
if manager != null:
|
||||
manager.call("delete_actor", _selected_actor_id)
|
||||
|
||||
func _on_status_changed(status: Dictionary) -> void:
|
||||
if not _is_open:
|
||||
return
|
||||
var enabled: bool = bool(status.get("enabled", false))
|
||||
var environment: String = str(status.get("environment", "未知"))
|
||||
var actors_variant: Variant = status.get("actors", [])
|
||||
var actor_count: int = (actors_variant as Array).size() if actors_variant is Array else 0
|
||||
var max_actors: int = int(status.get("maxActors", 20))
|
||||
_status_label.text = "%s · 假人 %d/%d" % [environment, actor_count, max_actors]
|
||||
_set_notice("实验室已启用" if enabled else "测试实验室未启用(仅 development/test 且 TEST_LAB_ENABLED=true)", ACCENT_COLOR if enabled else DANGER_COLOR.lightened(0.18))
|
||||
_render_actor_list(actors_variant)
|
||||
_render_online_players(status.get("onlinePlayers", []))
|
||||
|
||||
func _on_status_failed(message: String) -> void:
|
||||
if _is_open:
|
||||
_set_notice(message, DANGER_COLOR.lightened(0.18))
|
||||
|
||||
func _on_operation_completed(message: String) -> void:
|
||||
if _is_open:
|
||||
_message_input.text = ""
|
||||
_set_notice(message, ACCENT_COLOR)
|
||||
|
||||
func _render_actor_list(actors_variant: Variant) -> void:
|
||||
_clear_children(_actor_list)
|
||||
if not (actors_variant is Array) or (actors_variant as Array).is_empty():
|
||||
var empty := Label.new()
|
||||
empty.text = "尚未投放测试假人"
|
||||
empty.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
empty.add_theme_font_size_override("font_size", 14)
|
||||
_actor_list.add_child(empty)
|
||||
_selected_actor_id = ""
|
||||
_selected_label.text = "选择一名假人开始控制"
|
||||
return
|
||||
var found_selected: bool = false
|
||||
for actor_variant: Variant in actors_variant as Array:
|
||||
if not (actor_variant is Dictionary):
|
||||
continue
|
||||
var actor: Dictionary = actor_variant as Dictionary
|
||||
var actor_id: String = str(actor.get("userId", ""))
|
||||
if actor_id == _selected_actor_id:
|
||||
found_selected = true
|
||||
_actor_list.add_child(_actor_row(actor))
|
||||
if not found_selected:
|
||||
var first_actor_variant: Variant = (actors_variant as Array)[0]
|
||||
if first_actor_variant is Dictionary:
|
||||
_select_actor(first_actor_variant as Dictionary)
|
||||
|
||||
func _actor_row(actor: Dictionary) -> Button:
|
||||
var actor_id: String = str(actor.get("userId", ""))
|
||||
var nickname: String = str(actor.get("nickname", actor.get("username", "测试假人")))
|
||||
var map_id: String = str(actor.get("mapId", ""))
|
||||
var online: bool = bool(actor.get("online", false))
|
||||
var selected: bool = actor_id == _selected_actor_id
|
||||
var button := Button.new()
|
||||
button.custom_minimum_size = Vector2(0, 64)
|
||||
button.text = "%s · %s\n%s · %d, %d" % ["●" if online else "○", nickname, map_id, int(actor.get("x", 0)), int(actor.get("y", 0))]
|
||||
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
button.focus_mode = Control.FOCUS_NONE
|
||||
button.add_theme_font_size_override("font_size", 14)
|
||||
button.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
button.add_theme_stylebox_override("normal", _style(Color(0.08, 0.23, 0.30, 1.0) if selected else Color(0.05, 0.14, 0.20, 1.0), 10, ACCENT_COLOR if selected else Color(0.16, 0.36, 0.46, 0.52), 1))
|
||||
button.add_theme_stylebox_override("hover", _style(Color(0.10, 0.29, 0.37, 1.0), 10, ACCENT_COLOR.darkened(0.10), 1))
|
||||
button.pressed.connect(func() -> void: _select_actor(actor))
|
||||
return button
|
||||
|
||||
func _select_actor(actor: Dictionary) -> void:
|
||||
_selected_actor_id = str(actor.get("userId", ""))
|
||||
var nickname: String = str(actor.get("nickname", actor.get("username", "测试假人")))
|
||||
_selected_label.text = "控制 · %s" % nickname
|
||||
_actor_online.button_pressed = bool(actor.get("online", false))
|
||||
_actor_x.value = int(actor.get("x", 1280))
|
||||
_actor_y.value = int(actor.get("y", 960))
|
||||
_select_option(_actor_map, str(actor.get("mapId", "whale_port")))
|
||||
_select_option(_actor_skin, str(actor.get("skinId", "classic_whale")))
|
||||
_select_option(_policy_select, str(actor.get("roomVisitPolicy", "public")))
|
||||
var manager := _manager()
|
||||
if manager != null and manager.has_method("get_status"):
|
||||
var status_variant: Variant = manager.call("get_status")
|
||||
if status_variant is Dictionary:
|
||||
_render_actor_list((status_variant as Dictionary).get("actors", []))
|
||||
|
||||
func _render_online_players(players_variant: Variant) -> void:
|
||||
_target_select.clear()
|
||||
if not (players_variant is Array) or (players_variant as Array).is_empty():
|
||||
_target_select.add_item("暂无在线真实玩家")
|
||||
_target_select.set_item_metadata(0, "")
|
||||
return
|
||||
for player_variant: Variant in players_variant as Array:
|
||||
if not (player_variant is Dictionary):
|
||||
continue
|
||||
var player: Dictionary = player_variant as Dictionary
|
||||
var user_id: String = str(player.get("userId", ""))
|
||||
var username: String = str(player.get("username", user_id))
|
||||
var map_id: String = str(player.get("mapId", ""))
|
||||
_target_select.add_item("%s · %s" % [username, map_id])
|
||||
_target_select.set_item_metadata(_target_select.item_count - 1, user_id)
|
||||
|
||||
func _selected_option(select: OptionButton) -> String:
|
||||
if select.selected < 0:
|
||||
return ""
|
||||
return str(select.get_item_metadata(select.selected))
|
||||
|
||||
func _selected_target_id() -> String:
|
||||
return _selected_option(_target_select)
|
||||
|
||||
func _select_option(select: OptionButton, value: String) -> void:
|
||||
for index: int in range(select.item_count):
|
||||
if str(select.get_item_metadata(index)) == value:
|
||||
select.select(index)
|
||||
return
|
||||
if select.item_count > 0:
|
||||
select.select(0)
|
||||
|
||||
func _ensure_selected_actor() -> bool:
|
||||
if not _selected_actor_id.is_empty():
|
||||
return true
|
||||
_set_notice("请先从左侧选择测试假人", DANGER_COLOR.lightened(0.18))
|
||||
return false
|
||||
|
||||
func _set_notice(message: String, color: Color) -> void:
|
||||
_notice_label.visible = not message.strip_edges().is_empty()
|
||||
_notice_label.text = message
|
||||
_notice_label.add_theme_color_override("font_color", color)
|
||||
|
||||
func _clear_children(node: Node) -> void:
|
||||
for child: Node in node.get_children():
|
||||
child.queue_free()
|
||||
|
||||
func _is_admin() -> bool:
|
||||
var manager := _manager()
|
||||
return manager != null and manager.has_method("is_admin") and bool(manager.call("is_admin"))
|
||||
|
||||
func _manager() -> Node:
|
||||
return get_node_or_null("/root/TestLabManager")
|
||||
|
||||
func _connect_signal(source: Node, signal_name: StringName, callback: Callable) -> void:
|
||||
if source.has_signal(signal_name) and not source.is_connected(signal_name, callback):
|
||||
source.connect(signal_name, callback)
|
||||
|
||||
func _disconnect_signal(source: Node, signal_name: StringName, callback: Callable) -> void:
|
||||
if source.has_signal(signal_name) and source.is_connected(signal_name, callback):
|
||||
source.disconnect(signal_name, callback)
|
||||
1
scenes/ui/AdminTestLabPanel.gd.uid
Normal file
1
scenes/ui/AdminTestLabPanel.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cma7is74nvb3u
|
||||
@@ -680,6 +680,7 @@ func _registration_texture_rect(nodeName: String, texturePath: String, rect: Rec
|
||||
return textureRect
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("whaletown_escape_dismissible")
|
||||
_auth_manager = get_node_or_null("/root/AuthManager")
|
||||
_chat_manager = get_node_or_null("/root/ChatManager")
|
||||
_scene_manager = get_node_or_null("/root/SceneManager")
|
||||
@@ -1625,6 +1626,15 @@ func _hide_skin_workshop() -> void:
|
||||
if is_instance_valid(skin_workshop_overlay):
|
||||
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:
|
||||
var err := DisplayServer.file_dialog_show(
|
||||
"选择角色参考图片",
|
||||
|
||||
@@ -30,6 +30,7 @@ var _resignConfirmButton: Button
|
||||
var _resignCancelButton: Button
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("whaletown_escape_dismissible")
|
||||
chatFrame.visible = false
|
||||
purchaseButton.pressed.connect(_on_purchase_pressed)
|
||||
sendButton.pressed.connect(_on_send_pressed)
|
||||
@@ -238,6 +239,18 @@ func _on_close_pressed() -> void:
|
||||
_waitingForReply = false
|
||||
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:
|
||||
var servicePointId := str(_resignTarget.get("service_point_id", "")).strip_edges()
|
||||
if servicePointId.is_empty():
|
||||
|
||||
@@ -32,6 +32,7 @@ var _isSubmitting: bool = false
|
||||
var _isFetchingModels: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("whaletown_escape_dismissible")
|
||||
recruitmentFrame.visible = false
|
||||
_setup_protocol_options()
|
||||
_setup_employment_duration_options()
|
||||
@@ -398,6 +399,15 @@ func _on_close_pressed() -> void:
|
||||
fetchModelsButton.disabled = false
|
||||
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:
|
||||
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))
|
||||
|
||||
@@ -125,6 +125,7 @@ var _send_failure_handled_by_ui: bool = false
|
||||
|
||||
# 准备就绪
|
||||
func _ready() -> void:
|
||||
add_to_group("whaletown_escape_dismissible")
|
||||
_configure_mouse_focus()
|
||||
|
||||
# 初始隐藏聊天框
|
||||
@@ -182,6 +183,8 @@ func _get_settings_manager() -> Node:
|
||||
|
||||
# 处理全局输入
|
||||
func _input(event: InputEvent) -> void:
|
||||
if get_viewport().is_input_handled():
|
||||
return
|
||||
if event is InputEventMouseButton:
|
||||
_handle_global_mouse_button_input(event as InputEventMouseButton)
|
||||
return
|
||||
@@ -366,6 +369,15 @@ func hide_chat(immediate: bool = false) -> void:
|
||||
_transition_tween.tween_property(chat_panel, "modulate:a", 0.0, CHAT_TRANSITION_DURATION)
|
||||
_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:
|
||||
_hide_timer = Timer.new()
|
||||
|
||||
@@ -29,6 +29,7 @@ var _isOpen: bool = false
|
||||
var _transitionTween: Tween
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("whaletown_escape_dismissible")
|
||||
visible = false
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
set_process(false)
|
||||
@@ -39,6 +40,8 @@ func _notification(what: int) -> void:
|
||||
_positionPanel()
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if get_viewport().is_input_handled():
|
||||
return
|
||||
if not _isOpen:
|
||||
return
|
||||
if event is InputEventKey:
|
||||
@@ -59,6 +62,9 @@ func show_panel() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
set_process(true)
|
||||
_releaseMovementInputState()
|
||||
var taskManager := get_node_or_null("/root/TaskManager")
|
||||
if taskManager != null and taskManager.has_method("report_activity"):
|
||||
taskManager.call("report_activity", "course_board_opened")
|
||||
_fetchCourses()
|
||||
_animatePanel(true)
|
||||
|
||||
@@ -72,6 +78,15 @@ func hide_panel() -> void:
|
||||
func is_panel_open() -> bool:
|
||||
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:
|
||||
_overlay = ColorRect.new()
|
||||
_overlay.name = "courseBoardDimOverlay"
|
||||
|
||||
@@ -96,6 +96,7 @@ var _isLoading: bool = false
|
||||
var _debugShowRankSlots: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("whaletown_escape_dismissible")
|
||||
get_tree().paused = true
|
||||
_disableChatUiMouseInput()
|
||||
_buildUi()
|
||||
@@ -109,12 +110,23 @@ func _exit_tree() -> void:
|
||||
_restoreChatUiMouseInput()
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if get_viewport().is_input_handled():
|
||||
return
|
||||
if event is InputEventKey:
|
||||
var keyEvent := event as InputEventKey
|
||||
if keyEvent.pressed and not keyEvent.echo and keyEvent.keycode == KEY_ESCAPE:
|
||||
_onClosePressed()
|
||||
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:
|
||||
_rootControl = Control.new()
|
||||
_rootControl.name = "RankingPanelRoot"
|
||||
|
||||
@@ -38,6 +38,7 @@ var _isOpen: bool = false
|
||||
var _hasRequestedFriendList: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("whaletown_escape_dismissible")
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_build_ui()
|
||||
set_panel_open(false)
|
||||
@@ -60,6 +61,15 @@ func set_panel_open(open: bool) -> void:
|
||||
func is_panel_open() -> bool:
|
||||
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:
|
||||
set_panel_open(not _isOpen)
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ func _draw() -> void:
|
||||
_draw_friends()
|
||||
"settings":
|
||||
_draw_settings()
|
||||
"admin":
|
||||
_draw_admin()
|
||||
_:
|
||||
_draw_map()
|
||||
|
||||
@@ -101,6 +103,13 @@ func _draw_settings() -> void:
|
||||
_draw_arc(center, 4.9, 0.0, TAU, 32, LINE_WIDTH)
|
||||
_draw_arc(center, 2.0, 0.0, TAU, 24, DETAIL_WIDTH)
|
||||
|
||||
func _draw_admin() -> void:
|
||||
_draw_round_rect(Rect2(7.0, 5.8, 13.0, 15.6), 3.0)
|
||||
_draw_line(Vector2(9.5, 11.0), Vector2(17.5, 11.0), DETAIL_WIDTH)
|
||||
_draw_line(Vector2(9.5, 14.5), Vector2(15.3, 14.5), DETAIL_WIDTH)
|
||||
_draw_arc(Vector2(13.5, 20.5), 3.5, PI, TAU, 16, DETAIL_WIDTH)
|
||||
_draw_red_dot(Vector2(20.2, 6.0))
|
||||
|
||||
func _draw_round_rect(rect: Rect2, radius: float, width: float = LINE_WIDTH) -> void:
|
||||
var left := rect.position.x
|
||||
var top := rect.position.y
|
||||
|
||||
@@ -37,13 +37,13 @@ const MAP_CONFIGS: Dictionary = {
|
||||
"YSortWorld",
|
||||
],
|
||||
"markers": [
|
||||
{"label": "码头", "icon": "anchor", "pos": Vector2(0.160, 0.395), "side": "right"},
|
||||
{"label": "总部", "icon": "home", "pos": Vector2(0.505, 0.188), "side": "right"},
|
||||
{"label": "广场", "icon": "whale", "pos": Vector2(0.505, 0.515), "side": "right"},
|
||||
{"label": "小屋", "icon": "home", "pos": Vector2(0.830, 0.400), "side": "right"},
|
||||
{"label": "工坊", "icon": "tool", "pos": Vector2(0.752, 0.760), "side": "left"},
|
||||
{"label": "公告", "icon": "notice", "pos": Vector2(0.288, 0.838), "side": "right"},
|
||||
{"label": "入口", "icon": "gate", "pos": Vector2(0.500, 0.900), "side": "right"},
|
||||
{"label": "码头", "destinationId": "square_dock", "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": "广场", "destinationId": "square_center", "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": "工坊", "destinationId": "square_workshop", "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": "入口", "destinationId": "square_work_zone_gate", "icon": "gate", "pos": Vector2(0.500, 0.900), "side": "right"},
|
||||
],
|
||||
},
|
||||
"WorkZone": {
|
||||
@@ -57,13 +57,13 @@ const MAP_CONFIGS: Dictionary = {
|
||||
"YSortWorld",
|
||||
],
|
||||
"markers": [
|
||||
{"label": "商城", "icon": "home", "pos": Vector2(0.500, 0.180), "side": "right"},
|
||||
{"label": "咖啡店", "icon": "home", "pos": Vector2(0.092, 0.620), "side": "right"},
|
||||
{"label": "任务", "icon": "notice", "pos": Vector2(0.304, 0.600), "side": "right"},
|
||||
{"label": "课程", "icon": "notice", "pos": Vector2(0.694, 0.500), "side": "left"},
|
||||
{"label": "AI站", "icon": "tool", "pos": Vector2(0.676, 0.785), "side": "left"},
|
||||
{"label": "鲸币", "icon": "whale", "pos": Vector2(0.920, 0.785), "side": "left"},
|
||||
{"label": "入口", "icon": "gate", "pos": Vector2(0.500, 0.900), "side": "right"},
|
||||
{"label": "商城", "destinationId": "work_mall", "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": "任务", "destinationId": "work_jobs", "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": "AI站", "destinationId": "work_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": "入口", "destinationId": "work_entrance", "icon": "gate", "pos": Vector2(0.500, 0.900), "side": "right"},
|
||||
],
|
||||
},
|
||||
"CafeInterior": {
|
||||
@@ -74,11 +74,19 @@ const MAP_CONFIGS: Dictionary = {
|
||||
"CafeServiceHallBase",
|
||||
],
|
||||
"markers": [
|
||||
{"label": "服务台", "icon": "whale", "pos": Vector2(0.500, 0.465), "side": "right"},
|
||||
{"label": "陪伴区", "icon": "notice", "pos": Vector2(0.240, 0.280), "side": "right"},
|
||||
{"label": "出口", "icon": "gate", "pos": Vector2(0.500, 0.855), "side": "right"},
|
||||
{"label": "服务台", "destinationId": "cafe_counter", "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": "出口", "destinationId": "cafe_entrance", "icon": "gate", "pos": Vector2(0.500, 0.855), "side": "right"},
|
||||
],
|
||||
},
|
||||
"PersonalSpace": {
|
||||
"title": "我的房间",
|
||||
"worldSize": Vector2(1024, 768),
|
||||
"sourceNodes": [
|
||||
"RoomBase",
|
||||
],
|
||||
"markers": [],
|
||||
},
|
||||
}
|
||||
|
||||
var _panel: PanelContainer
|
||||
@@ -90,8 +98,13 @@ var _mapCamera: Camera2D
|
||||
var _titleLabel: Label
|
||||
var _markerNodes: Array[Control] = []
|
||||
var _isOpen: bool = false
|
||||
var _travelDestinations: Dictionary = {}
|
||||
var _travelLocked: bool = false
|
||||
var _destinationMenu: PopupMenu
|
||||
var _destinationMenuIds: Dictionary = {}
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("whaletown_escape_dismissible")
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_build_ui()
|
||||
set_panel_open(false)
|
||||
@@ -107,6 +120,8 @@ func _notification(what: int) -> void:
|
||||
_anchor_panel()
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if get_viewport().is_input_handled():
|
||||
return
|
||||
if not _isOpen:
|
||||
return
|
||||
if event is InputEventKey:
|
||||
@@ -123,10 +138,20 @@ func set_panel_open(open: bool) -> void:
|
||||
_panel.visible = _isOpen
|
||||
if _isOpen:
|
||||
_rebuild_minimap_world()
|
||||
_load_travel_destinations()
|
||||
|
||||
func is_panel_open() -> bool:
|
||||
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:
|
||||
set_panel_open(not _isOpen)
|
||||
|
||||
@@ -152,6 +177,9 @@ func _build_ui() -> void:
|
||||
|
||||
content.add_child(_build_header())
|
||||
content.add_child(_build_map_view())
|
||||
_destinationMenu = PopupMenu.new()
|
||||
_destinationMenu.id_pressed.connect(_on_destination_menu_selected)
|
||||
add_child(_destinationMenu)
|
||||
|
||||
func _build_header() -> Control:
|
||||
var header := HBoxContainer.new()
|
||||
@@ -173,6 +201,20 @@ func _build_header() -> Control:
|
||||
title.add_theme_font_size_override("font_size", 24)
|
||||
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()
|
||||
closeButton.text = "×"
|
||||
closeButton.tooltip_text = "关闭地图"
|
||||
@@ -263,6 +305,16 @@ 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("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())
|
||||
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()
|
||||
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
@@ -281,7 +333,7 @@ func _create_marker(marker: Dictionary) -> Control:
|
||||
|
||||
var label := Label.new()
|
||||
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
label.text = str(marker.get("label", "地点"))
|
||||
label.text = ("🔒 " if not unlocked else "") + str(marker.get("label", "地点"))
|
||||
label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
label.add_theme_font_size_override("font_size", 14)
|
||||
row.add_child(label)
|
||||
@@ -295,6 +347,117 @@ func _create_marker(marker: Dictionary) -> Control:
|
||||
)
|
||||
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:
|
||||
_panel.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||
_panel.offset_left = -PANEL_SIZE.x - PANEL_MARGIN_RIGHT
|
||||
|
||||
@@ -24,6 +24,7 @@ var _chatUiPrevMouseFilter: Control.MouseFilter = Control.MOUSE_FILTER_STOP
|
||||
var _chatUiMouseDisabled: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("whaletown_escape_dismissible")
|
||||
get_tree().paused = true
|
||||
_disableChatUiMouseInput()
|
||||
|
||||
@@ -39,6 +40,15 @@ func _ready() -> void:
|
||||
func _exit_tree() -> void:
|
||||
_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:
|
||||
for child in dotsContainer.get_children():
|
||||
child.queue_free()
|
||||
|
||||
@@ -8,7 +8,7 @@ extends Control
|
||||
# ============================================================================
|
||||
|
||||
const HUD_MARGIN: Vector2 = Vector2(16, 16)
|
||||
const SHORTCUT_BAR_SIZE: Vector2 = Vector2(350, 86)
|
||||
const SHORTCUT_BAR_SIZE: Vector2 = Vector2(476, 86)
|
||||
const PLAYER_PROFILE_BUTTON_SIZE: Vector2 = Vector2(210, 78)
|
||||
const PLAYER_AVATAR_SIZE: Vector2 = Vector2(58, 58)
|
||||
const HUD_SEPARATION: int = 18
|
||||
@@ -24,6 +24,10 @@ var _walletLabel: Label
|
||||
var _avatarLabel: Label
|
||||
var _avatarPanel: PanelContainer
|
||||
var _walletRefreshTimer: Timer
|
||||
var _taskShortcutButton: Button
|
||||
var _taskRewardBadge: Label
|
||||
var _shortcutRow: HBoxContainer
|
||||
var _adminShortcutButton: Button
|
||||
|
||||
var _currentUsername: String = "玩家"
|
||||
var _walletBalance: int = 0
|
||||
@@ -37,6 +41,7 @@ func _ready() -> void:
|
||||
_subscribe_to_events()
|
||||
_load_current_user()
|
||||
_refresh_wallet()
|
||||
call_deferred("_refresh_admin_shortcut")
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var eventSystem := _get_event_system()
|
||||
@@ -59,6 +64,11 @@ func _exit_tree() -> void:
|
||||
var callback := Callable(self, "_on_auth_state_changed")
|
||||
if authManager.is_connected("auth_state_changed", callback):
|
||||
authManager.disconnect("auth_state_changed", callback)
|
||||
var taskManager := get_node_or_null("/root/TaskManager")
|
||||
if taskManager != null and taskManager.has_signal("board_changed"):
|
||||
var taskCallback := Callable(self, "_on_task_board_changed")
|
||||
if taskManager.is_connected("board_changed", taskCallback):
|
||||
taskManager.disconnect("board_changed", taskCallback)
|
||||
|
||||
func _build_ui() -> void:
|
||||
var rootRow := HBoxContainer.new()
|
||||
@@ -87,15 +97,35 @@ func _build_shortcut_bar() -> PanelContainer:
|
||||
panel.add_child(margin)
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
_shortcutRow = row
|
||||
row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
row.add_theme_constant_override("separation", 8)
|
||||
margin.add_child(row)
|
||||
|
||||
row.add_child(_create_shortcut_button("map", "地图", _on_map_pressed))
|
||||
row.add_child(_create_shortcut_button("task", "任务", _on_task_pressed))
|
||||
_taskShortcutButton = _create_shortcut_button("task", "任务", _on_task_pressed)
|
||||
_taskShortcutButton.tooltip_text = "打开任务书"
|
||||
_taskRewardBadge = Label.new()
|
||||
_taskRewardBadge.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_taskRewardBadge.visible = false
|
||||
_taskRewardBadge.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_taskRewardBadge.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_taskRewardBadge.add_theme_color_override("font_color", Color.WHITE)
|
||||
_taskRewardBadge.add_theme_font_size_override("font_size", 12)
|
||||
_taskRewardBadge.add_theme_stylebox_override("normal", _create_pill_style(Color(0.92, 0.30, 0.24), 12))
|
||||
_taskRewardBadge.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||
_taskRewardBadge.offset_left = -20
|
||||
_taskRewardBadge.offset_top = 1
|
||||
_taskRewardBadge.offset_right = 4
|
||||
_taskRewardBadge.offset_bottom = 25
|
||||
_taskRewardBadge.z_index = 1
|
||||
_taskShortcutButton.add_child(_taskRewardBadge)
|
||||
row.add_child(_taskShortcutButton)
|
||||
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("activity", "通知", _on_notifications_pressed))
|
||||
row.add_child(_create_shortcut_button("settings", "设置", _on_settings_pressed))
|
||||
_refresh_admin_shortcut()
|
||||
|
||||
return panel
|
||||
|
||||
@@ -239,6 +269,15 @@ func _subscribe_to_events() -> void:
|
||||
var callback := Callable(self, "_on_auth_state_changed")
|
||||
if not authManager.is_connected("auth_state_changed", callback):
|
||||
authManager.connect("auth_state_changed", callback)
|
||||
var taskManager := get_node_or_null("/root/TaskManager")
|
||||
if taskManager != null and taskManager.has_signal("board_changed"):
|
||||
var taskCallback := Callable(self, "_on_task_board_changed")
|
||||
if not taskManager.is_connected("board_changed", taskCallback):
|
||||
taskManager.connect("board_changed", taskCallback)
|
||||
if taskManager.has_method("get_board"):
|
||||
var boardVariant: Variant = taskManager.call("get_board")
|
||||
if boardVariant is Dictionary:
|
||||
_on_task_board_changed(boardVariant as Dictionary)
|
||||
|
||||
func _load_current_user() -> void:
|
||||
var authManager := get_node_or_null("/root/AuthManager")
|
||||
@@ -262,9 +301,11 @@ func _on_auth_state_changed(isAuthenticated: bool, user: Dictionary) -> void:
|
||||
if not isAuthenticated:
|
||||
_set_username("玩家")
|
||||
_reset_wallet()
|
||||
_refresh_admin_shortcut()
|
||||
return
|
||||
_apply_auth_user_payload({"user": user})
|
||||
_refresh_wallet()
|
||||
_refresh_admin_shortcut()
|
||||
|
||||
func _apply_auth_user_payload(data: Dictionary) -> void:
|
||||
var userVariant: Variant = data.get("user", {})
|
||||
@@ -272,10 +313,51 @@ func _apply_auth_user_payload(data: Dictionary) -> void:
|
||||
var username := str((userVariant as Dictionary).get("username", "")).strip_edges()
|
||||
if not username.is_empty():
|
||||
_set_username(username)
|
||||
_refresh_admin_shortcut()
|
||||
|
||||
func _on_auth_logout(_data: Variant = null) -> void:
|
||||
_set_username("玩家")
|
||||
_reset_wallet()
|
||||
_on_task_board_changed({})
|
||||
_refresh_admin_shortcut()
|
||||
|
||||
func _on_task_board_changed(board: Dictionary) -> void:
|
||||
if not is_instance_valid(_taskShortcutButton):
|
||||
return
|
||||
var claimableCount: int = _count_claimable_task_rewards(board)
|
||||
if claimableCount > 0:
|
||||
_taskShortcutButton.tooltip_text = "任务书:有 %d 项奖励可领取" % claimableCount
|
||||
_taskShortcutButton.modulate = Color(1.0, 0.855, 0.470, 1.0)
|
||||
if is_instance_valid(_taskRewardBadge):
|
||||
_taskRewardBadge.text = str(mini(claimableCount, 9)) if claimableCount < 10 else "9+"
|
||||
_taskRewardBadge.visible = true
|
||||
return
|
||||
_taskShortcutButton.tooltip_text = "打开任务书"
|
||||
_taskShortcutButton.modulate = Color.WHITE
|
||||
if is_instance_valid(_taskRewardBadge):
|
||||
_taskRewardBadge.visible = false
|
||||
|
||||
func _count_claimable_task_rewards(board: Dictionary) -> int:
|
||||
var claimableCount: int = 0
|
||||
claimableCount += _count_claimable_tasks(board.get("newbie_tasks", []))
|
||||
claimableCount += _count_claimable_tasks(board.get("weekly_tasks", []))
|
||||
var weeklyBonusVariant: Variant = board.get("weekly_bonus", {})
|
||||
if weeklyBonusVariant is Dictionary:
|
||||
var weeklyBonus: Dictionary = weeklyBonusVariant as Dictionary
|
||||
if bool(weeklyBonus.get("claimable", false)):
|
||||
claimableCount += 1
|
||||
return claimableCount
|
||||
|
||||
func _count_claimable_tasks(tasksVariant: Variant) -> int:
|
||||
if not (tasksVariant is Array):
|
||||
return 0
|
||||
var claimableCount: int = 0
|
||||
for taskVariant: Variant in tasksVariant as Array:
|
||||
if taskVariant is Dictionary:
|
||||
var task: Dictionary = taskVariant as Dictionary
|
||||
if bool(task.get("claimable", false)):
|
||||
claimableCount += 1
|
||||
return claimableCount
|
||||
|
||||
func _on_appearance_avatar_changed(_data: Dictionary) -> void:
|
||||
_apply_current_avatar()
|
||||
@@ -379,8 +461,15 @@ func _on_map_pressed() -> void:
|
||||
if eventSystem != null:
|
||||
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:
|
||||
_emit_status_message("任务入口稍后接入")
|
||||
var eventSystem := _get_event_system()
|
||||
if eventSystem != null:
|
||||
eventSystem.call("emit_event", EventNames.HUD_TASK_BOOK_REQUESTED, {})
|
||||
|
||||
func _on_backpack_pressed() -> void:
|
||||
var eventSystem := _get_event_system()
|
||||
@@ -402,6 +491,35 @@ func _on_avatar_pressed() -> void:
|
||||
if eventSystem != null:
|
||||
eventSystem.call("emit_event", EventNames.HUD_SETTINGS_REQUESTED, {})
|
||||
|
||||
func _on_admin_test_lab_pressed() -> void:
|
||||
var eventSystem := _get_event_system()
|
||||
if eventSystem != null:
|
||||
eventSystem.call("emit_event", EventNames.HUD_ADMIN_TEST_LAB_REQUESTED, {})
|
||||
|
||||
func _refresh_admin_shortcut() -> void:
|
||||
if not is_instance_valid(_shortcutRow):
|
||||
return
|
||||
var shouldShow: bool = _is_current_user_admin()
|
||||
if shouldShow and not is_instance_valid(_adminShortcutButton):
|
||||
_adminShortcutButton = _create_shortcut_button("admin", "管理", _on_admin_test_lab_pressed)
|
||||
_adminShortcutButton.tooltip_text = "管理员测试实验室"
|
||||
_shortcutRow.add_child(_adminShortcutButton)
|
||||
elif not shouldShow and is_instance_valid(_adminShortcutButton):
|
||||
_adminShortcutButton.queue_free()
|
||||
_adminShortcutButton = null
|
||||
|
||||
func _is_current_user_admin() -> bool:
|
||||
var authManager := get_node_or_null("/root/AuthManager")
|
||||
if authManager == null or not authManager.has_method("is_authenticated") or not bool(authManager.call("is_authenticated")):
|
||||
return false
|
||||
if not authManager.has_method("get_current_user"):
|
||||
return false
|
||||
var userVariant: Variant = authManager.call("get_current_user")
|
||||
if not (userVariant is Dictionary):
|
||||
return false
|
||||
var user: Dictionary = userVariant as Dictionary
|
||||
return int(user.get("role", 0)) == 9
|
||||
|
||||
func _emit_status_message(message: String) -> void:
|
||||
var eventSystem := _get_event_system()
|
||||
if eventSystem != null:
|
||||
|
||||
@@ -38,6 +38,7 @@ const DEFAULT_SETTINGS: Dictionary = {
|
||||
"ui_scale": 1.00,
|
||||
"fullscreen": false,
|
||||
"show_interaction_hints": true,
|
||||
"show_interaction_points": false,
|
||||
"show_name_always": false,
|
||||
"show_chat_bubbles": true,
|
||||
"world_notifications": true,
|
||||
@@ -45,6 +46,7 @@ const DEFAULT_SETTINGS: Dictionary = {
|
||||
"friend_request_notifications": true,
|
||||
"allow_nearby_private": true,
|
||||
"allow_nearby_friend_requests": true,
|
||||
"allow_nearby_profile": true,
|
||||
"mute_ui_sfx": false,
|
||||
}
|
||||
|
||||
@@ -73,6 +75,7 @@ var _transitionTween: Tween
|
||||
var _avatarUploadPending: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("whaletown_escape_dismissible")
|
||||
visible = false
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
set_process(false)
|
||||
@@ -95,6 +98,8 @@ func _notification(what: int) -> void:
|
||||
_position_panel()
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if get_viewport().is_input_handled():
|
||||
return
|
||||
if not _isOpen:
|
||||
return
|
||||
if event is InputEventKey:
|
||||
@@ -136,6 +141,15 @@ func hide_panel(saveBeforeClose: bool = false) -> void:
|
||||
func is_panel_open() -> bool:
|
||||
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:
|
||||
_overlay = ColorRect.new()
|
||||
_overlay.name = "SettingsDimOverlay"
|
||||
@@ -344,6 +358,7 @@ func _render_basic_content() -> void:
|
||||
_content.add_child(_create_section("基础设置", [
|
||||
_create_toggle_row("window", "全屏显示", "fullscreen", "适合专注游玩,窗口模式方便调试"),
|
||||
_create_toggle_row("eye", "显示互动提示", "show_interaction_hints", "靠近 NPC、好友或公告板时显示按键提示"),
|
||||
_create_toggle_row("eye", "显示交互点", "show_interaction_points", "用白色圆圈直接标出当前地图内的交互目标"),
|
||||
_create_toggle_row("account", "始终显示名字", "show_name_always", "关闭时只在需要时显示玩家名称"),
|
||||
]))
|
||||
_content.add_child(_create_section("音频设置", [
|
||||
@@ -365,25 +380,27 @@ func _render_chat_content() -> void:
|
||||
_content.add_child(_create_section("聊天与社交", [
|
||||
_create_toggle_row("chat", "世界频道提醒", "world_notifications", "收到世界频道消息时保留轻提示"),
|
||||
_create_toggle_row("chat", "私聊提醒", "private_notifications", "好友或附近玩家私聊时提示"),
|
||||
_create_toggle_row("account", "好友请求提醒", "friend_request_notifications", "对方按 F 发起好友申请时显示在好友列表"),
|
||||
_create_toggle_row("account", "好友请求提醒", "friend_request_notifications", "附近玩家发起好友申请时显示在好友列表"),
|
||||
_create_toggle_row("chat", "显示聊天气泡", "show_chat_bubbles", "气泡发送会同时进入世界频道"),
|
||||
]))
|
||||
_content.add_child(_create_section("附近玩家权限", [
|
||||
_create_toggle_row("eye", "允许查看我的名片", "allow_nearby_profile", "关闭后陌生玩家无法在附近打开你的社区名片"),
|
||||
_create_toggle_row("chat", "允许附近私聊", "allow_nearby_private", "附近玩家按 E 可以发起悄悄话"),
|
||||
_create_toggle_row("account", "允许好友申请", "allow_nearby_friend_requests", "附近玩家按 F 可以发送好友申请"),
|
||||
_create_toggle_row("account", "允许好友申请", "allow_nearby_friend_requests", "附近玩家可通过互动列表发送好友申请"),
|
||||
]))
|
||||
|
||||
func _render_controls_content() -> void:
|
||||
_content.add_child(_create_section("操作说明", [
|
||||
_create_key_row("W A S D", "移动角色"),
|
||||
_create_key_row("方向键", "备用移动"),
|
||||
_create_key_row("E", "互动 / 附近私聊"),
|
||||
_create_key_row("F", "发送好友申请"),
|
||||
_create_key_row("方向键", "附近互动列表选择"),
|
||||
_create_key_row("E", "执行选中的互动"),
|
||||
_create_key_row("Esc", "关闭当前界面或结束当前互动"),
|
||||
_create_key_row("T", "打开聊天输入"),
|
||||
_create_key_row("Enter", "发送聊天消息"),
|
||||
]))
|
||||
_content.add_child(_create_section("操作辅助", [
|
||||
_create_toggle_row("eye", "显示互动提示", "show_interaction_hints", "靠近可交互目标时显示按键提示"),
|
||||
_create_toggle_row("eye", "显示交互点", "show_interaction_points", "用白色圆圈直接标出当前地图内的交互目标"),
|
||||
_create_hint_row("当前版本使用固定键位;这里的开关会控制地图内的互动提示显示。"),
|
||||
]))
|
||||
|
||||
|
||||
337
scenes/ui/TaskBookPanel.gd
Normal file
337
scenes/ui/TaskBookPanel.gd
Normal file
@@ -0,0 +1,337 @@
|
||||
extends CanvasLayer
|
||||
|
||||
const PANEL_SIZE := Vector2(980, 860)
|
||||
const TEXT_COLOR := Color(0.10, 0.18, 0.27)
|
||||
const MUTED_COLOR := Color(0.38, 0.48, 0.58)
|
||||
const ACCENT_COLOR := Color(0.10, 0.49, 0.85)
|
||||
const COIN_COLOR := Color(0.94, 0.58, 0.08)
|
||||
const WELCOME_DIALOG_SCENE: PackedScene = preload("res://scenes/ui/welcome_dialog.tscn")
|
||||
const WELCOME_DIALOG_NAME := "WelcomeDialog"
|
||||
|
||||
var _root: Control
|
||||
var _panel: PanelContainer
|
||||
var _content: VBoxContainer
|
||||
var _status_label: Label
|
||||
var _is_open: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 32
|
||||
add_to_group("whaletown_escape_dismissible")
|
||||
_build_ui()
|
||||
_connect_services()
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var event_system := get_node_or_null("/root/EventSystem")
|
||||
if event_system != null:
|
||||
event_system.call("disconnect_event", EventNames.HUD_TASK_BOOK_REQUESTED, _on_task_book_requested, self)
|
||||
var task_manager := get_node_or_null("/root/TaskManager")
|
||||
if task_manager != null:
|
||||
var board_callback := Callable(self, "_on_board_changed")
|
||||
if task_manager.has_signal("board_changed") and task_manager.is_connected("board_changed", board_callback):
|
||||
task_manager.disconnect("board_changed", board_callback)
|
||||
var failed_callback := Callable(self, "_on_board_failed")
|
||||
if task_manager.has_signal("board_failed") and task_manager.is_connected("board_failed", failed_callback):
|
||||
task_manager.disconnect("board_failed", failed_callback)
|
||||
|
||||
func show_panel() -> void:
|
||||
_is_open = true
|
||||
_root.visible = true
|
||||
var task_manager := get_node_or_null("/root/TaskManager")
|
||||
if task_manager != null:
|
||||
if task_manager.has_method("is_loaded") and bool(task_manager.call("is_loaded")):
|
||||
var board_variant: Variant = task_manager.call("get_board")
|
||||
if board_variant is Dictionary:
|
||||
_render_board(board_variant as Dictionary)
|
||||
if task_manager.has_method("refresh_board"):
|
||||
task_manager.call("refresh_board")
|
||||
|
||||
func hide_panel() -> void:
|
||||
_is_open = false
|
||||
_root.visible = false
|
||||
|
||||
func is_escape_dismissible() -> bool:
|
||||
return _is_open
|
||||
|
||||
func get_escape_priority() -> int:
|
||||
return 760
|
||||
|
||||
func request_escape_close() -> void:
|
||||
hide_panel()
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if not _is_open or get_viewport().is_input_handled():
|
||||
return
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
hide_panel()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
func _connect_services() -> void:
|
||||
var event_system := get_node_or_null("/root/EventSystem")
|
||||
if event_system != null:
|
||||
event_system.call("connect_event", EventNames.HUD_TASK_BOOK_REQUESTED, _on_task_book_requested, self)
|
||||
var task_manager := get_node_or_null("/root/TaskManager")
|
||||
if task_manager != null:
|
||||
if task_manager.has_signal("board_changed"):
|
||||
task_manager.connect("board_changed", _on_board_changed)
|
||||
if task_manager.has_signal("board_failed"):
|
||||
task_manager.connect("board_failed", _on_board_failed)
|
||||
|
||||
func _build_ui() -> void:
|
||||
_root = Control.new()
|
||||
_root.name = "TaskBookRoot"
|
||||
_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_root.visible = false
|
||||
add_child(_root)
|
||||
|
||||
var dim := ColorRect.new()
|
||||
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
dim.color = Color(0.02, 0.05, 0.09, 0.68)
|
||||
dim.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
dim.gui_input.connect(func(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton and (event as InputEventMouseButton).pressed:
|
||||
hide_panel()
|
||||
)
|
||||
_root.add_child(dim)
|
||||
|
||||
_panel = PanelContainer.new()
|
||||
_panel.custom_minimum_size = PANEL_SIZE
|
||||
_panel.set_anchors_preset(Control.PRESET_CENTER)
|
||||
_panel.offset_left = -PANEL_SIZE.x * 0.5
|
||||
_panel.offset_top = -PANEL_SIZE.y * 0.5
|
||||
_panel.offset_right = PANEL_SIZE.x * 0.5
|
||||
_panel.offset_bottom = PANEL_SIZE.y * 0.5
|
||||
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_panel.add_theme_stylebox_override("panel", _panel_style(Color(0.96, 0.985, 1.0, 1.0), 28))
|
||||
_root.add_child(_panel)
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 28)
|
||||
margin.add_theme_constant_override("margin_top", 24)
|
||||
margin.add_theme_constant_override("margin_right", 28)
|
||||
margin.add_theme_constant_override("margin_bottom", 24)
|
||||
_panel.add_child(margin)
|
||||
var layout := VBoxContainer.new()
|
||||
layout.add_theme_constant_override("separation", 16)
|
||||
margin.add_child(layout)
|
||||
layout.add_child(_build_header())
|
||||
_status_label = Label.new()
|
||||
_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
_status_label.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
_status_label.add_theme_font_size_override("font_size", 15)
|
||||
layout.add_child(_status_label)
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
layout.add_child(scroll)
|
||||
_content = VBoxContainer.new()
|
||||
_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_content.add_theme_constant_override("separation", 12)
|
||||
scroll.add_child(_content)
|
||||
var footer := Label.new()
|
||||
footer.text = "[Esc] 关闭任务书"
|
||||
footer.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
footer.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
footer.add_theme_font_size_override("font_size", 15)
|
||||
layout.add_child(footer)
|
||||
|
||||
func _build_header() -> Control:
|
||||
var header := PanelContainer.new()
|
||||
header.custom_minimum_size = Vector2(0, 96)
|
||||
header.add_theme_stylebox_override("panel", _panel_style(ACCENT_COLOR, 20))
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 24)
|
||||
margin.add_theme_constant_override("margin_top", 14)
|
||||
margin.add_theme_constant_override("margin_right", 16)
|
||||
margin.add_theme_constant_override("margin_bottom", 14)
|
||||
header.add_child(margin)
|
||||
var row := HBoxContainer.new()
|
||||
row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
margin.add_child(row)
|
||||
var title_box := VBoxContainer.new()
|
||||
title_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
row.add_child(title_box)
|
||||
var title := Label.new()
|
||||
title.text = "鲸镇任务书"
|
||||
title.add_theme_color_override("font_color", Color.WHITE)
|
||||
title.add_theme_font_size_override("font_size", 30)
|
||||
title_box.add_child(title)
|
||||
var subtitle := Label.new()
|
||||
subtitle.text = "完成探索、学习与社交任务,领取鲸币奖励"
|
||||
subtitle.add_theme_color_override("font_color", Color(0.88, 0.95, 1.0, 1.0))
|
||||
subtitle.add_theme_font_size_override("font_size", 16)
|
||||
title_box.add_child(subtitle)
|
||||
var guide := Button.new()
|
||||
guide.text = "新人引导"
|
||||
guide.focus_mode = Control.FOCUS_NONE
|
||||
guide.custom_minimum_size = Vector2(104, 42)
|
||||
guide.add_theme_stylebox_override("normal", _panel_style(Color(1, 1, 1, 0.18), 14))
|
||||
guide.add_theme_stylebox_override("hover", _panel_style(Color(1, 1, 1, 0.30), 14))
|
||||
guide.add_theme_color_override("font_color", Color.WHITE)
|
||||
guide.pressed.connect(_on_guide_pressed)
|
||||
row.add_child(guide)
|
||||
var close := Button.new()
|
||||
close.text = "关闭"
|
||||
close.focus_mode = Control.FOCUS_NONE
|
||||
close.custom_minimum_size = Vector2(84, 42)
|
||||
close.add_theme_stylebox_override("normal", _panel_style(Color(1, 1, 1, 0.18), 14))
|
||||
close.add_theme_stylebox_override("hover", _panel_style(Color(1, 1, 1, 0.30), 14))
|
||||
close.add_theme_color_override("font_color", Color.WHITE)
|
||||
close.pressed.connect(hide_panel)
|
||||
row.add_child(close)
|
||||
return header
|
||||
|
||||
func _on_task_book_requested(_data: Dictionary = {}) -> void:
|
||||
show_panel()
|
||||
|
||||
func _on_guide_pressed() -> void:
|
||||
hide_panel()
|
||||
var root: Window = get_tree().root
|
||||
var dialog: Node = root.get_node_or_null(WELCOME_DIALOG_NAME)
|
||||
if dialog == null:
|
||||
dialog = WELCOME_DIALOG_SCENE.instantiate()
|
||||
dialog.name = WELCOME_DIALOG_NAME
|
||||
root.add_child(dialog)
|
||||
if dialog.has_method("_showGuide"):
|
||||
dialog.call_deferred("_showGuide")
|
||||
|
||||
func _on_board_changed(board: Dictionary) -> void:
|
||||
if _is_open:
|
||||
_render_board(board)
|
||||
|
||||
func _on_board_failed(message: String) -> void:
|
||||
if _is_open and is_instance_valid(_status_label):
|
||||
_status_label.text = message
|
||||
_status_label.add_theme_color_override("font_color", Color(0.86, 0.28, 0.24))
|
||||
|
||||
func _render_board(board: Dictionary) -> void:
|
||||
_clear_content()
|
||||
if board.is_empty():
|
||||
_status_label.text = "正在读取任务书…"
|
||||
return
|
||||
_status_label.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
var cycle_variant: Variant = board.get("weekly_cycle", {})
|
||||
var cycle: Dictionary = cycle_variant as Dictionary if cycle_variant is Dictionary else {}
|
||||
_status_label.text = "周常重置:%s" % _format_reset(str(cycle.get("ends_at", "")))
|
||||
_add_section("新人主线", "可自由完成,首次购买皮肤为可跳过任务")
|
||||
_add_tasks(board.get("newbie_tasks", []))
|
||||
_add_section("本周任务", "四项全部完成后可领取额外 200 鲸币")
|
||||
_add_tasks(board.get("weekly_tasks", []))
|
||||
var bonus_variant: Variant = board.get("weekly_bonus", {})
|
||||
if bonus_variant is Dictionary:
|
||||
_content.add_child(_build_task_card(bonus_variant as Dictionary))
|
||||
|
||||
func _add_section(title_text: String, description: String) -> void:
|
||||
var section := VBoxContainer.new()
|
||||
section.add_theme_constant_override("separation", 2)
|
||||
var title := Label.new()
|
||||
title.text = title_text
|
||||
title.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
title.add_theme_font_size_override("font_size", 23)
|
||||
section.add_child(title)
|
||||
var subtitle := Label.new()
|
||||
subtitle.text = description
|
||||
subtitle.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
subtitle.add_theme_font_size_override("font_size", 15)
|
||||
section.add_child(subtitle)
|
||||
_content.add_child(section)
|
||||
|
||||
func _add_tasks(tasks_variant: Variant) -> void:
|
||||
if not (tasks_variant is Array):
|
||||
return
|
||||
for task_variant: Variant in tasks_variant as Array:
|
||||
if task_variant is Dictionary:
|
||||
_content.add_child(_build_task_card(task_variant as Dictionary))
|
||||
|
||||
func _build_task_card(task: Dictionary) -> Control:
|
||||
var completed: bool = bool(task.get("completed", false))
|
||||
var claimed: bool = bool(task.get("claimed", false))
|
||||
var optional: bool = bool(task.get("optional", false))
|
||||
var card := PanelContainer.new()
|
||||
card.add_theme_stylebox_override("panel", _panel_style(Color(0.90, 0.96, 1.0, 0.92) if completed else Color.WHITE, 18))
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 18)
|
||||
margin.add_theme_constant_override("margin_top", 14)
|
||||
margin.add_theme_constant_override("margin_right", 18)
|
||||
margin.add_theme_constant_override("margin_bottom", 14)
|
||||
card.add_child(margin)
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 14)
|
||||
margin.add_child(row)
|
||||
var state := Label.new()
|
||||
state.custom_minimum_size = Vector2(30, 0)
|
||||
state.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
state.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
state.text = "✓" if claimed else ("●" if completed else "○")
|
||||
state.add_theme_color_override("font_color", Color(0.17, 0.66, 0.41) if completed else MUTED_COLOR)
|
||||
state.add_theme_font_size_override("font_size", 24)
|
||||
row.add_child(state)
|
||||
var detail := VBoxContainer.new()
|
||||
detail.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
detail.add_theme_constant_override("separation", 4)
|
||||
row.add_child(detail)
|
||||
var title := Label.new()
|
||||
title.text = "%s%s" % [str(task.get("title", "任务")), "(可跳过)" if optional else ""]
|
||||
title.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
title.add_theme_font_size_override("font_size", 19)
|
||||
detail.add_child(title)
|
||||
var description := Label.new()
|
||||
description.text = str(task.get("description", ""))
|
||||
description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
description.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
description.add_theme_font_size_override("font_size", 15)
|
||||
detail.add_child(description)
|
||||
var progress: int = int(task.get("progress", 0))
|
||||
var target: int = max(1, int(task.get("target", 1)))
|
||||
var progress_label := Label.new()
|
||||
progress_label.text = "进度 %d / %d" % [min(progress, target), target]
|
||||
progress_label.add_theme_color_override("font_color", ACCENT_COLOR)
|
||||
progress_label.add_theme_font_size_override("font_size", 14)
|
||||
detail.add_child(progress_label)
|
||||
var reward := Label.new()
|
||||
reward.text = "+%d 鲸币" % int(task.get("reward", 0))
|
||||
reward.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
reward.add_theme_color_override("font_color", COIN_COLOR)
|
||||
reward.add_theme_font_size_override("font_size", 17)
|
||||
row.add_child(reward)
|
||||
var action := Button.new()
|
||||
action.focus_mode = Control.FOCUS_NONE
|
||||
action.custom_minimum_size = Vector2(88, 42)
|
||||
if claimed:
|
||||
action.text = "已领取"
|
||||
action.disabled = true
|
||||
elif completed:
|
||||
action.text = "领取"
|
||||
action.pressed.connect(func() -> void:
|
||||
var task_manager := get_node_or_null("/root/TaskManager")
|
||||
if task_manager != null and task_manager.has_method("claim_task"):
|
||||
task_manager.call("claim_task", str(task.get("id", "")))
|
||||
)
|
||||
else:
|
||||
action.text = "进行中"
|
||||
action.disabled = true
|
||||
action.add_theme_stylebox_override("normal", _panel_style(ACCENT_COLOR if completed and not claimed else Color(0.80, 0.85, 0.89), 12))
|
||||
action.add_theme_stylebox_override("hover", _panel_style(Color(0.06, 0.39, 0.71), 12))
|
||||
action.add_theme_color_override("font_color", Color.WHITE)
|
||||
row.add_child(action)
|
||||
return card
|
||||
|
||||
func _clear_content() -> void:
|
||||
for child: Node in _content.get_children():
|
||||
child.queue_free()
|
||||
|
||||
func _format_reset(value: String) -> String:
|
||||
if value.is_empty():
|
||||
return "下周一 00:00"
|
||||
return value.replace("T", " ").left(16)
|
||||
|
||||
func _panel_style(color: Color, radius: int) -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = color
|
||||
style.corner_radius_top_left = radius
|
||||
style.corner_radius_top_right = radius
|
||||
style.corner_radius_bottom_left = radius
|
||||
style.corner_radius_bottom_right = radius
|
||||
style.shadow_color = Color(0.04, 0.13, 0.22, 0.24)
|
||||
style.shadow_size = 16
|
||||
style.shadow_offset = Vector2(0, 6)
|
||||
return style
|
||||
1
scenes/ui/TaskBookPanel.gd.uid
Normal file
1
scenes/ui/TaskBookPanel.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d2b7h12k8nqoi
|
||||
@@ -11,7 +11,7 @@ const GUIDE_PAGES: Array[Dictionary] = [
|
||||
"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- 输入框获得焦点时,角色移动会暂停响应。",
|
||||
"text": "操作提示:\n\n- 按 [color=#ffaa00]E[/color] 键可以与 NPC、公告板和信息板互动。\n- 靠近目标后面向它,再按互动键。\n- 按 [color=#ffaa00]Esc[/color] 可以关闭当前界面或结束当前互动。\n- 输入框获得焦点时,角色移动会暂停响应。",
|
||||
"image_path": "res://assets/maps/square/v1/props/bottom_entrance_right_service_props_v2_hd_clean.png",
|
||||
},
|
||||
]
|
||||
@@ -34,10 +34,12 @@ var _guideFooter: HBoxContainer
|
||||
var _guidePrevButton: Button
|
||||
var _guideNextButton: Button
|
||||
var _guideDotsContainer: HBoxContainer
|
||||
var _guideTaskBookButton: Button
|
||||
var _guideCurrentPage: int = 0
|
||||
var _guideTween: Tween
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("whaletown_escape_dismissible")
|
||||
_disableChatUiMouseInput()
|
||||
|
||||
var closeButton := find_child("CloseButton", true, false) as Button
|
||||
@@ -52,8 +54,20 @@ func _exit_tree() -> void:
|
||||
_restoreChatUiMouseInput()
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if get_viewport().is_input_handled():
|
||||
return
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
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:
|
||||
queue_free()
|
||||
@@ -62,6 +76,9 @@ func _onStartPressed() -> void:
|
||||
_showGuide()
|
||||
|
||||
func _showGuide() -> void:
|
||||
var taskManager := get_node_or_null("/root/TaskManager")
|
||||
if taskManager != null and taskManager.has_method("report_activity"):
|
||||
taskManager.call("report_activity", "guide_opened")
|
||||
panelContainer.custom_minimum_size = Vector2(720, 560)
|
||||
titleLabel.text = "新人引导手册"
|
||||
logoContainer.visible = false
|
||||
@@ -149,6 +166,12 @@ func _buildGuideUi() -> void:
|
||||
_guideNextButton.pressed.connect(_onGuideNextPressed)
|
||||
_guideFooter.add_child(_guideNextButton)
|
||||
|
||||
_guideTaskBookButton = _createGuideButton("任务书")
|
||||
_guideTaskBookButton.name = "TaskBookButton"
|
||||
_guideTaskBookButton.custom_minimum_size = Vector2(96, 44)
|
||||
_guideTaskBookButton.pressed.connect(_onGuideTaskBookPressed)
|
||||
_guideFooter.add_child(_guideTaskBookButton)
|
||||
|
||||
func _createGuideButton(text: String) -> Button:
|
||||
var button := Button.new()
|
||||
button.custom_minimum_size = Vector2(56, 44)
|
||||
@@ -272,6 +295,12 @@ func _onGuideNextPressed() -> void:
|
||||
_guideCurrentPage += 1
|
||||
_updateGuideUi()
|
||||
|
||||
func _onGuideTaskBookPressed() -> void:
|
||||
var taskBook := get_node_or_null("/root/TaskBookPanel")
|
||||
if taskBook != null and taskBook.has_method("show_panel"):
|
||||
taskBook.call("show_panel")
|
||||
queue_free()
|
||||
|
||||
func _disableChatUiMouseInput() -> void:
|
||||
var currentScene := get_tree().current_scene
|
||||
if currentScene != null:
|
||||
|
||||
@@ -62,6 +62,7 @@ var _transitionTween: Tween
|
||||
var _toastTween: Tween
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("whaletown_escape_dismissible")
|
||||
visible = false
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
set_process(false)
|
||||
@@ -82,6 +83,8 @@ func _notification(what: int) -> void:
|
||||
_position_panel()
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if get_viewport().is_input_handled():
|
||||
return
|
||||
if not _isOpen:
|
||||
return
|
||||
if event is InputEventKey:
|
||||
@@ -120,6 +123,18 @@ func hide_panel() -> void:
|
||||
func is_panel_open() -> bool:
|
||||
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:
|
||||
_overlay = ColorRect.new()
|
||||
_overlay.name = "mallDimOverlay"
|
||||
|
||||
Reference in New Issue
Block a user