forked from xiangwang25/whale-town-front-v2
Initial WhaleTown V2 frontend
This commit is contained in:
285
scenes/Maps/CafeInterior.gd
Normal file
285
scenes/Maps/CafeInterior.gd
Normal file
@@ -0,0 +1,285 @@
|
||||
class_name CafeInterior
|
||||
extends Node2D
|
||||
|
||||
# ============================================================================
|
||||
# CafeInterior.gd - 鲸鱼咖啡馆室内场景控制器
|
||||
# ============================================================================
|
||||
# 承载咖啡馆挂机服务大厅,负责玩家出生点、相机边界和返回工作区。
|
||||
# ============================================================================
|
||||
|
||||
const CAMERA_ZOOM: Vector2 = Vector2(1.65, 1.65)
|
||||
const CAMERA_LIMIT_LEFT: int = -768
|
||||
const CAMERA_LIMIT_TOP: int = -512
|
||||
const CAMERA_LIMIT_RIGHT: int = 768
|
||||
const CAMERA_LIMIT_BOTTOM: int = 512
|
||||
const WORK_ZONE_CAFE_RETURN_POSITION: Vector2 = Vector2(-1093, 560)
|
||||
const CAFE_DOOR_POSITION: Vector2 = Vector2(0, 392)
|
||||
const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres")
|
||||
const COMPANION_NAMEPLATE_RENDER_SCALE: float = 0.5
|
||||
const COMPANION_NAMEPLATE_FONT_SIZE: int = 24
|
||||
const COMPANION_NAMEPLATE_VISUAL_HEIGHT: int = 22
|
||||
const COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH: int = 76
|
||||
const COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH: int = 118
|
||||
const COMPANION_NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12
|
||||
const NPC_NAMEPLATE_OFFSET_Y: float = -136.0
|
||||
const HIRED_PLAYER_NAMEPLATE_OFFSET_Y: float = -96.0
|
||||
|
||||
@onready var player: PlayerController = $YSortWorld/Characters/Players/Player
|
||||
@onready var playerCamera: Camera2D = $YSortWorld/Characters/Players/Player/Camera2D
|
||||
@onready var hiredPlayerCompanionTarget: CafeCompanionTarget = $YSortWorld/Characters/Players/Player/CafeHiredCompanionTarget
|
||||
@onready var hiredPlayerNameplate: Label = $YSortWorld/Characters/Players/Player/CafeHiredCompanionNameplate
|
||||
@onready var exitToWorkZoneArea: Area2D = $InteractionAreas/ExitToWorkZoneArea
|
||||
@onready var cafeRecruitmentLogoArea: Area2D = $InteractionAreas/CafeRecruitmentLogoArea
|
||||
@onready var serviceIdlePoint01: Marker2D = $Markers/ServiceIdlePoints/ServiceIdlePoint01
|
||||
@onready var cafeWhaleBaristaNpc: NPCController = $YSortWorld/Characters/CafeWhaleBaristaNpc
|
||||
@onready var cafeCompanionNameplate: Label = $YSortWorld/Characters/CafeWhaleBaristaNpc/CafeCompanionNameplate
|
||||
|
||||
var _isChangingScene: bool = false
|
||||
var _lastRecruitmentClickMsec: int = 0
|
||||
|
||||
func _ready() -> void:
|
||||
_align_service_occupants()
|
||||
_configure_static_companion_nameplates()
|
||||
_apply_spawn_point()
|
||||
_configure_camera()
|
||||
_connect_exit_area()
|
||||
_connect_recruitment_area()
|
||||
_connect_cafe_companion_events()
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var eventSystem := get_node_or_null("/root/EventSystem")
|
||||
if eventSystem != null:
|
||||
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_AGENT_REGISTERED, _on_cafe_companion_agent_registered, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_EMPLOYMENT_RESIGNED, _on_cafe_companion_employment_resigned, self)
|
||||
|
||||
func _align_service_occupants() -> void:
|
||||
cafeWhaleBaristaNpc.global_position = serviceIdlePoint01.global_position
|
||||
|
||||
func _apply_spawn_point() -> void:
|
||||
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
|
||||
if marker == null:
|
||||
marker = $Markers/DefaultSpawn
|
||||
player.global_position = marker.global_position
|
||||
|
||||
func _configure_camera() -> void:
|
||||
playerCamera.enabled = true
|
||||
playerCamera.make_current()
|
||||
playerCamera.zoom = CAMERA_ZOOM
|
||||
playerCamera.position_smoothing_enabled = true
|
||||
playerCamera.limit_left = CAMERA_LIMIT_LEFT
|
||||
playerCamera.limit_top = CAMERA_LIMIT_TOP
|
||||
playerCamera.limit_right = CAMERA_LIMIT_RIGHT
|
||||
playerCamera.limit_bottom = CAMERA_LIMIT_BOTTOM
|
||||
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)
|
||||
|
||||
func _connect_recruitment_area() -> void:
|
||||
cafeRecruitmentLogoArea.input_pickable = true
|
||||
if not cafeRecruitmentLogoArea.input_event.is_connected(_on_recruitment_area_input_event):
|
||||
cafeRecruitmentLogoArea.input_event.connect(_on_recruitment_area_input_event)
|
||||
set_process_unhandled_input(true)
|
||||
|
||||
func _connect_cafe_companion_events() -> void:
|
||||
var eventSystem := get_node_or_null("/root/EventSystem")
|
||||
if eventSystem == null:
|
||||
return
|
||||
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 _on_exit_area_body_entered(body: Node2D) -> void:
|
||||
if _isChangingScene or body != player:
|
||||
return
|
||||
_isChangingScene = true
|
||||
SceneManager.set_next_scene_position(WORK_ZONE_CAFE_RETURN_POSITION)
|
||||
SceneManager.change_scene("work_zone")
|
||||
|
||||
func _on_recruitment_area_input_event(_viewport: Viewport, event: InputEvent, _shapeIdx: int) -> void:
|
||||
if not _is_primary_mouse_click(event):
|
||||
return
|
||||
get_viewport().set_input_as_handled()
|
||||
_try_emit_recruitment_selected()
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not _is_primary_mouse_click(event):
|
||||
return
|
||||
if not _area_contains_global_point(cafeRecruitmentLogoArea, get_global_mouse_position()):
|
||||
return
|
||||
get_viewport().set_input_as_handled()
|
||||
_try_emit_recruitment_selected()
|
||||
|
||||
func _try_emit_recruitment_selected() -> void:
|
||||
var now := Time.get_ticks_msec()
|
||||
if _lastRecruitmentClickMsec > 0 and now - _lastRecruitmentClickMsec < 120:
|
||||
return
|
||||
_lastRecruitmentClickMsec = now
|
||||
_emit_recruitment_selected()
|
||||
|
||||
func _emit_recruitment_selected() -> void:
|
||||
var eventSystem := get_node_or_null("/root/EventSystem")
|
||||
if eventSystem == null:
|
||||
return
|
||||
eventSystem.call("emit_event", EventNames.CAFE_COMPANION_RECRUITMENT_SELECTED, {
|
||||
"cafe_id": "whale_cafe",
|
||||
"terminal_id": "front_reception_whale_logo",
|
||||
"title": "咖啡店陪伴招聘",
|
||||
"anchor_position": cafeRecruitmentLogoArea.global_position,
|
||||
})
|
||||
|
||||
func _is_primary_mouse_click(event: InputEvent) -> bool:
|
||||
if not (event is InputEventMouseButton):
|
||||
return false
|
||||
var mouseEvent := event as InputEventMouseButton
|
||||
return mouseEvent.button_index == MOUSE_BUTTON_LEFT and mouseEvent.pressed
|
||||
|
||||
func _area_contains_global_point(area: Area2D, globalPoint: Vector2) -> bool:
|
||||
for child in area.get_children():
|
||||
var shapeNode := child as CollisionShape2D
|
||||
if shapeNode == null or shapeNode.disabled or shapeNode.shape == null:
|
||||
continue
|
||||
if _shape_contains_point(shapeNode, globalPoint):
|
||||
return true
|
||||
return false
|
||||
|
||||
func _shape_contains_point(shapeNode: CollisionShape2D, globalPoint: Vector2) -> bool:
|
||||
var localPoint := shapeNode.to_local(globalPoint)
|
||||
var shape := shapeNode.shape
|
||||
if shape is RectangleShape2D:
|
||||
var rectangle := shape as RectangleShape2D
|
||||
return Rect2(rectangle.size * -0.5, rectangle.size).has_point(localPoint)
|
||||
if shape is CircleShape2D:
|
||||
var circle := shape as CircleShape2D
|
||||
return localPoint.length() <= circle.radius
|
||||
return false
|
||||
|
||||
func _on_cafe_companion_agent_registered(data: Dictionary) -> void:
|
||||
var companionData := _extract_companion_data(data)
|
||||
if companionData.is_empty():
|
||||
return
|
||||
|
||||
var servicePointId := str(companionData.get("service_point_id", "")).strip_edges()
|
||||
var servicePointMarker := _service_point_marker(servicePointId)
|
||||
if servicePointMarker == null:
|
||||
push_warning("CafeInterior: 服务点不存在,无法摆放被雇佣玩家: %s" % servicePointId)
|
||||
return
|
||||
|
||||
var personaName := str(companionData.get("persona_name", "陪伴机器人")).strip_edges()
|
||||
player.global_position = servicePointMarker.global_position
|
||||
player.velocity = Vector2.ZERO
|
||||
player.lastDirection = "down"
|
||||
player.set_movement_locked(true)
|
||||
player.call("_play_idle_animation")
|
||||
player.call("_update_world_sort_z")
|
||||
_configure_hired_player_companion_target(companionData, servicePointId, personaName)
|
||||
_configure_hired_player_nameplate(personaName)
|
||||
|
||||
func _on_cafe_companion_employment_resigned(_data: Dictionary) -> void:
|
||||
player.set_movement_locked(false)
|
||||
_disable_hired_player_companion_target()
|
||||
hiredPlayerNameplate.visible = false
|
||||
player.global_position = CAFE_DOOR_POSITION
|
||||
player.velocity = Vector2.ZERO
|
||||
player.lastDirection = "down"
|
||||
player.call("_play_idle_animation")
|
||||
player.call("_update_world_sort_z")
|
||||
|
||||
func _extract_companion_data(data: Dictionary) -> Dictionary:
|
||||
var companionVariant: Variant = data.get("companion", {})
|
||||
if companionVariant is Dictionary:
|
||||
return companionVariant
|
||||
if data.has("service_point_id"):
|
||||
return data
|
||||
return {}
|
||||
|
||||
func _service_point_marker(servicePointId: String) -> Marker2D:
|
||||
if servicePointId.is_empty():
|
||||
return null
|
||||
return $Markers/ServiceIdlePoints.get_node_or_null(servicePointId) as Marker2D
|
||||
|
||||
func _configure_hired_player_companion_target(companionData: Dictionary, servicePointId: String, personaName: String) -> void:
|
||||
hiredPlayerCompanionTarget.servicePointId = servicePointId
|
||||
hiredPlayerCompanionTarget.companionId = str(companionData.get("id", "")).strip_edges()
|
||||
if hiredPlayerCompanionTarget.companionId.is_empty():
|
||||
hiredPlayerCompanionTarget.companionId = str(companionData.get("companion_id", "")).strip_edges()
|
||||
hiredPlayerCompanionTarget.companionType = str(companionData.get("companion_type", "hired_player")).strip_edges()
|
||||
hiredPlayerCompanionTarget.personaName = personaName
|
||||
hiredPlayerCompanionTarget.ownerUserId = str(companionData.get("owner_user_id", "")).strip_edges()
|
||||
hiredPlayerCompanionTarget.employmentEndsAt = str(companionData.get("employment_ends_at", "")).strip_edges()
|
||||
hiredPlayerCompanionTarget.selfTarget = true
|
||||
hiredPlayerCompanionTarget.input_pickable = true
|
||||
|
||||
var shapeNode := hiredPlayerCompanionTarget.get_node_or_null("CollisionShape2D") as CollisionShape2D
|
||||
if shapeNode != null:
|
||||
shapeNode.disabled = false
|
||||
|
||||
func _disable_hired_player_companion_target() -> void:
|
||||
hiredPlayerCompanionTarget.input_pickable = false
|
||||
hiredPlayerCompanionTarget.selfTarget = false
|
||||
var shapeNode := hiredPlayerCompanionTarget.get_node_or_null("CollisionShape2D") as CollisionShape2D
|
||||
if shapeNode != null:
|
||||
shapeNode.disabled = true
|
||||
|
||||
func _configure_hired_player_nameplate(personaName: String) -> void:
|
||||
_configure_companion_nameplate(hiredPlayerNameplate, personaName, HIRED_PLAYER_NAMEPLATE_OFFSET_Y)
|
||||
hiredPlayerNameplate.visible = true
|
||||
|
||||
func _configure_static_companion_nameplates() -> void:
|
||||
_configure_companion_nameplate(cafeCompanionNameplate, cafeWhaleBaristaNpc.npcName, NPC_NAMEPLATE_OFFSET_Y)
|
||||
cafeCompanionNameplate.visible = true
|
||||
|
||||
func _configure_companion_nameplate(label: Label, personaName: String, offsetY: float) -> void:
|
||||
var displayName := personaName.strip_edges()
|
||||
if displayName.is_empty():
|
||||
displayName = "陪伴机器人"
|
||||
var visualWidth := _companion_nameplate_visual_width(displayName)
|
||||
var renderWidth := ceili(float(visualWidth) / COMPANION_NAMEPLATE_RENDER_SCALE)
|
||||
var renderHeight := ceili(float(COMPANION_NAMEPLATE_VISUAL_HEIGHT) / COMPANION_NAMEPLATE_RENDER_SCALE)
|
||||
|
||||
label.theme = WORLD_TEXT_THEME
|
||||
label.text = displayName
|
||||
label.visible = true
|
||||
label.z_index = 30
|
||||
label.scale = Vector2.ONE * COMPANION_NAMEPLATE_RENDER_SCALE
|
||||
label.position = Vector2(float(visualWidth) * -0.5, offsetY)
|
||||
label.custom_minimum_size = Vector2(renderWidth, renderHeight)
|
||||
label.size = label.custom_minimum_size
|
||||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
label.clip_text = true
|
||||
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
label.add_theme_color_override("font_color", Color(0.12, 0.20, 0.24, 1.0))
|
||||
label.add_theme_color_override("font_shadow_color", Color(1.0, 1.0, 1.0, 0.85))
|
||||
label.add_theme_constant_override("shadow_offset_x", 0)
|
||||
label.add_theme_constant_override("shadow_offset_y", 1)
|
||||
label.add_theme_font_size_override("font_size", COMPANION_NAMEPLATE_FONT_SIZE)
|
||||
label.add_theme_stylebox_override("normal", _create_companion_nameplate_style())
|
||||
|
||||
func _companion_nameplate_visual_width(displayName: String) -> int:
|
||||
var estimatedWidth := displayName.length() * COMPANION_NAMEPLATE_VISUAL_CHAR_WIDTH + 28
|
||||
return clampi(estimatedWidth, COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH, COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH)
|
||||
|
||||
func _create_companion_nameplate_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(1.0, 0.988, 0.955, 0.96)
|
||||
style.border_color = Color(0.18, 0.34, 0.38, 0.92)
|
||||
style.border_width_left = 2
|
||||
style.border_width_top = 2
|
||||
style.border_width_right = 2
|
||||
style.border_width_bottom = 2
|
||||
style.corner_radius_top_left = 12
|
||||
style.corner_radius_top_right = 12
|
||||
style.corner_radius_bottom_left = 12
|
||||
style.corner_radius_bottom_right = 12
|
||||
style.content_margin_left = 12
|
||||
style.content_margin_top = 4
|
||||
style.content_margin_right = 12
|
||||
style.content_margin_bottom = 4
|
||||
style.shadow_color = Color(0.05, 0.08, 0.09, 0.18)
|
||||
style.shadow_size = 4
|
||||
style.shadow_offset = Vector2(0, 2)
|
||||
return style
|
||||
1
scenes/Maps/CafeInterior.gd.uid
Normal file
1
scenes/Maps/CafeInterior.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ccesagjisqodl
|
||||
539
scenes/Maps/MapMultiplayerController.gd
Normal file
539
scenes/Maps/MapMultiplayerController.gd
Normal file
@@ -0,0 +1,539 @@
|
||||
extends Node
|
||||
|
||||
# ============================================================================
|
||||
# MapMultiplayerController.gd - 地图远端玩家同步
|
||||
# ============================================================================
|
||||
# 负责把本地玩家位置发送给聊天 WebSocket,并把后端位置广播渲染为远端玩家。
|
||||
# ============================================================================
|
||||
|
||||
const REMOTE_PLAYER_SCENE: PackedScene = preload("res://scenes/characters/remote_player.tscn")
|
||||
const CHAT_BUBBLE_SCENE: PackedScene = preload("res://scenes/ui/ChatBubble.tscn")
|
||||
const DEFAULT_MAP_ID: String = "whale_port"
|
||||
const POSITION_SEND_INTERVAL: float = 0.12
|
||||
const MIN_POSITION_DELTA: float = 4.0
|
||||
const CHAT_BUBBLE_LAYER_NAME: String = "WorldChatBubbleLayer"
|
||||
const CHAT_BUBBLE_TARGET_OFFSET: Vector2 = Vector2(0, -84)
|
||||
const PRIVATE_CHAT_INTERACTION_DISTANCE: float = 150.0
|
||||
const PRIVATE_CHAT_FORWARD_DOT_THRESHOLD: float = 0.25
|
||||
|
||||
@export var map_id: String = DEFAULT_MAP_ID
|
||||
@export var local_player_path: NodePath = NodePath("../Characters/Players/Player")
|
||||
@export var remote_players_root_path: NodePath = NodePath("../Characters/Players/RemotePlayers")
|
||||
|
||||
var _local_player: Node2D
|
||||
var _remote_players_root: Node2D
|
||||
var _remote_players: Dictionary = {}
|
||||
var _pending_remote_players: Dictionary = {}
|
||||
var _last_sent_position: Vector2 = Vector2.INF
|
||||
var _last_sent_at_msec: int = 0
|
||||
var _last_private_chat_interaction_msec: int = 0
|
||||
var _last_friend_request_interaction_msec: int = 0
|
||||
|
||||
func _ready() -> void:
|
||||
_local_player = get_node_or_null(local_player_path) as Node2D
|
||||
_remote_players_root = get_node_or_null(remote_players_root_path) as Node2D
|
||||
|
||||
if _local_player == null:
|
||||
push_warning("MapMultiplayerController: local player not found.")
|
||||
if _remote_players_root == null:
|
||||
push_warning("MapMultiplayerController: remote players root not found.")
|
||||
|
||||
var eventSystem := _get_event_system()
|
||||
if eventSystem == null:
|
||||
push_warning("MapMultiplayerController: EventSystem autoload is not available.")
|
||||
return
|
||||
|
||||
eventSystem.call("connect_event", EventNames.PLAYER_MOVED, _on_local_player_moved, self)
|
||||
eventSystem.call("connect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_chat_login_success, self)
|
||||
eventSystem.call("connect_event", EventNames.REMOTE_PLAYERS_SNAPSHOT_READY, _on_remote_players_snapshot_ready, self)
|
||||
eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_POSITION_UPDATED, _on_remote_player_position_updated, self)
|
||||
eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_JOINED, _on_remote_player_joined, self)
|
||||
eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_LEFT, _on_remote_player_left, self)
|
||||
eventSystem.call("connect_event", EventNames.REMOTE_SKIN_READY, _on_remote_skin_ready, self)
|
||||
eventSystem.call("connect_event", EventNames.REMOTE_SKIN_FAILED, _on_remote_skin_failed, self)
|
||||
eventSystem.call("connect_event", EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
|
||||
eventSystem.call("connect_event", EventNames.INTERACT_PRESSED, _on_interact_pressed, self)
|
||||
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 _exit_tree() -> void:
|
||||
var eventSystem := _get_event_system()
|
||||
if eventSystem == null:
|
||||
return
|
||||
|
||||
eventSystem.call("disconnect_event", EventNames.PLAYER_MOVED, _on_local_player_moved, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_chat_login_success, self)
|
||||
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYERS_SNAPSHOT_READY, _on_remote_players_snapshot_ready, self)
|
||||
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_POSITION_UPDATED, _on_remote_player_position_updated, self)
|
||||
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_JOINED, _on_remote_player_joined, self)
|
||||
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_LEFT, _on_remote_player_left, self)
|
||||
eventSystem.call("disconnect_event", EventNames.REMOTE_SKIN_READY, _on_remote_skin_ready, self)
|
||||
eventSystem.call("disconnect_event", EventNames.REMOTE_SKIN_FAILED, _on_remote_skin_failed, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
|
||||
eventSystem.call("disconnect_event", EventNames.INTERACT_PRESSED, _on_interact_pressed, self)
|
||||
|
||||
func _get_event_system() -> Node:
|
||||
return get_node_or_null("/root/EventSystem")
|
||||
|
||||
func _get_chat_manager() -> Node:
|
||||
return get_node_or_null("/root/ChatManager")
|
||||
|
||||
func _get_settings_manager() -> Node:
|
||||
return get_node_or_null("/root/SettingsManager")
|
||||
|
||||
func _find_chat_ui() -> Control:
|
||||
var current: Node = self
|
||||
while current != null:
|
||||
var chat_ui := current.get_node_or_null("UILayer/ChatUI") as Control
|
||||
if chat_ui != null:
|
||||
return chat_ui
|
||||
current = current.get_parent()
|
||||
return null
|
||||
|
||||
func _is_text_input_focused() -> bool:
|
||||
var focusOwner: Control = get_viewport().gui_get_focus_owner()
|
||||
if focusOwner == null or not focusOwner.is_inside_tree() or not focusOwner.is_visible_in_tree():
|
||||
return false
|
||||
if focusOwner is LineEdit:
|
||||
var lineEdit := focusOwner as LineEdit
|
||||
return lineEdit.editable
|
||||
if focusOwner is TextEdit:
|
||||
var textEdit := focusOwner as TextEdit
|
||||
return textEdit.editable
|
||||
return false
|
||||
|
||||
func _on_interact_pressed(_data: Dictionary = {}) -> void:
|
||||
_try_start_private_chat()
|
||||
|
||||
func _try_start_private_chat() -> bool:
|
||||
if _is_text_input_focused():
|
||||
return false
|
||||
if not _settings_bool("allow_nearby_private", true):
|
||||
return false
|
||||
|
||||
var now := Time.get_ticks_msec()
|
||||
if _last_private_chat_interaction_msec > 0 and now - _last_private_chat_interaction_msec < 160:
|
||||
return false
|
||||
|
||||
var target := _find_private_chat_target()
|
||||
if target == null:
|
||||
return false
|
||||
|
||||
var eventSystem := _get_event_system()
|
||||
if eventSystem == null:
|
||||
return false
|
||||
|
||||
_last_private_chat_interaction_msec = now
|
||||
eventSystem.call("emit_event", EventNames.CHAT_PRIVATE_TARGET_SELECTED, {
|
||||
"userId": str(target.get("userId")),
|
||||
"username": str(target.get("username"))
|
||||
})
|
||||
return true
|
||||
|
||||
func _try_request_friend() -> bool:
|
||||
if _is_text_input_focused():
|
||||
return false
|
||||
if not _settings_bool("allow_nearby_friend_requests", true):
|
||||
return false
|
||||
|
||||
var now := Time.get_ticks_msec()
|
||||
if _last_friend_request_interaction_msec > 0 and now - _last_friend_request_interaction_msec < 220:
|
||||
return false
|
||||
|
||||
var target := _find_private_chat_target()
|
||||
if target == null:
|
||||
return false
|
||||
|
||||
var chat_ui := _find_chat_ui()
|
||||
if chat_ui == null or not chat_ui.has_method("request_friend_with_target"):
|
||||
return false
|
||||
|
||||
_last_friend_request_interaction_msec = now
|
||||
return bool(chat_ui.call(
|
||||
"request_friend_with_target",
|
||||
str(target.get("userId")),
|
||||
str(target.get("username"))
|
||||
))
|
||||
|
||||
func _on_chat_login_success(_data: Dictionary) -> void:
|
||||
_send_world_ready()
|
||||
|
||||
func _on_local_player_moved(data: Dictionary) -> void:
|
||||
var position_variant: Variant = data.get("position", null)
|
||||
if position_variant is Vector2:
|
||||
_send_position(position_variant as Vector2)
|
||||
|
||||
func _send_world_ready() -> void:
|
||||
if _local_player == null:
|
||||
return
|
||||
var chatManager := _get_chat_manager()
|
||||
if chatManager == null or not chatManager.has_method("mark_world_ready"):
|
||||
return
|
||||
chatManager.call("mark_world_ready", _get_map_id(), _local_player.global_position)
|
||||
|
||||
func _send_position(position: Vector2, force: bool = false) -> void:
|
||||
var now := Time.get_ticks_msec()
|
||||
if not force and now - _last_sent_at_msec < int(POSITION_SEND_INTERVAL * 1000.0):
|
||||
return
|
||||
if not force and _last_sent_position != Vector2.INF and _last_sent_position.distance_to(position) < MIN_POSITION_DELTA:
|
||||
return
|
||||
|
||||
var chatManager := _get_chat_manager()
|
||||
if chatManager == null:
|
||||
return
|
||||
if chatManager.has_method("can_attempt_chat_connection") and not bool(chatManager.call("can_attempt_chat_connection")):
|
||||
return
|
||||
|
||||
_last_sent_position = position
|
||||
_last_sent_at_msec = now
|
||||
chatManager.call("update_player_position", position.x, position.y, _get_map_id())
|
||||
|
||||
func _on_remote_player_joined(data: Dictionary) -> void:
|
||||
if not _is_event_for_current_map(data):
|
||||
return
|
||||
var user_id := str(data.get("userId", "")).strip_edges()
|
||||
if user_id.is_empty():
|
||||
return
|
||||
var remote_player := _ensure_remote_player(user_id, data)
|
||||
var position_variant: Variant = data.get("position", null)
|
||||
if remote_player != null and position_variant is Vector2 and remote_player.has_method("update_position"):
|
||||
remote_player.call("update_position", position_variant as Vector2)
|
||||
|
||||
func _on_remote_players_snapshot_ready(data: Dictionary) -> void:
|
||||
if not _is_event_for_current_map(data):
|
||||
return
|
||||
|
||||
var seen_user_ids: Dictionary = {}
|
||||
var players_variant: Variant = data.get("players", [])
|
||||
if players_variant is Array:
|
||||
for player_variant in players_variant:
|
||||
if not (player_variant is Dictionary):
|
||||
continue
|
||||
var player_data: Dictionary = player_variant
|
||||
var user_id := str(player_data.get("userId", "")).strip_edges()
|
||||
if user_id.is_empty() or _is_current_user(user_id, str(player_data.get("username", ""))):
|
||||
continue
|
||||
seen_user_ids[user_id] = true
|
||||
var remote_player := _ensure_remote_player(user_id, player_data)
|
||||
var position_variant: Variant = player_data.get("position", null)
|
||||
if remote_player != null and position_variant is Vector2 and remote_player.has_method("update_position"):
|
||||
remote_player.call("update_position", position_variant as Vector2)
|
||||
|
||||
for user_id_variant in _remote_players.keys().duplicate():
|
||||
var user_id := str(user_id_variant)
|
||||
if seen_user_ids.has(user_id):
|
||||
continue
|
||||
var remote_player := _remote_players[user_id] as Node
|
||||
_remote_players.erase(user_id)
|
||||
if is_instance_valid(remote_player):
|
||||
remote_player.queue_free()
|
||||
for user_id_variant in _pending_remote_players.keys().duplicate():
|
||||
var pendingUserId := str(user_id_variant)
|
||||
if not seen_user_ids.has(pendingUserId):
|
||||
_pending_remote_players.erase(pendingUserId)
|
||||
|
||||
func _on_remote_player_position_updated(data: Dictionary) -> void:
|
||||
if not _is_event_for_current_map(data):
|
||||
return
|
||||
var user_id := str(data.get("userId", "")).strip_edges()
|
||||
if user_id.is_empty():
|
||||
return
|
||||
if _is_current_user(user_id, str(data.get("username", ""))):
|
||||
return
|
||||
|
||||
var position_variant: Variant = data.get("position", null)
|
||||
if not (position_variant is Vector2):
|
||||
return
|
||||
|
||||
var remote_player := _ensure_remote_player(user_id, data)
|
||||
if remote_player != null and remote_player.has_method("update_position"):
|
||||
remote_player.call("update_position", position_variant as Vector2)
|
||||
|
||||
func _on_remote_player_left(data: Dictionary) -> void:
|
||||
if not _is_event_for_current_map(data):
|
||||
return
|
||||
var user_id := str(data.get("userId", "")).strip_edges()
|
||||
if user_id.is_empty():
|
||||
return
|
||||
_pending_remote_players.erase(user_id)
|
||||
if not _remote_players.has(user_id):
|
||||
return
|
||||
|
||||
var remote_player := _remote_players[user_id] as Node
|
||||
_remote_players.erase(user_id)
|
||||
if is_instance_valid(remote_player):
|
||||
remote_player.queue_free()
|
||||
|
||||
func _on_chat_message_received(data: Dictionary) -> void:
|
||||
if not bool(data.get("show_bubble", false)):
|
||||
return
|
||||
if not _settings_bool("show_chat_bubbles", true):
|
||||
return
|
||||
|
||||
var content := str(data.get("content", "")).strip_edges()
|
||||
if content.is_empty():
|
||||
return
|
||||
|
||||
var target := _resolve_chat_bubble_target(data)
|
||||
if target == null:
|
||||
return
|
||||
|
||||
_show_chat_bubble(target, content)
|
||||
|
||||
func _ensure_remote_player(user_id: String, data: Dictionary) -> Node2D:
|
||||
if _is_current_user(user_id, str(data.get("username", ""))):
|
||||
return null
|
||||
var appearanceManager := get_node_or_null("/root/AppearanceManager")
|
||||
var skinId := str(data.get("skin_id", data.get("skinId", ""))).strip_edges()
|
||||
var skinAssetVariant: Variant = data.get("skin_asset", data.get("skinAsset", {}))
|
||||
var skinAsset: Dictionary = skinAssetVariant if skinAssetVariant is Dictionary else {}
|
||||
if appearanceManager != null and appearanceManager.has_method("is_remote_skin_ready"):
|
||||
if not bool(appearanceManager.call("is_remote_skin_ready", skinId, skinAsset)):
|
||||
_queue_pending_remote_player(user_id, data)
|
||||
if _remote_players.has(user_id):
|
||||
var loadingPlayer := _remote_players[user_id] as Node2D
|
||||
if is_instance_valid(loadingPlayer):
|
||||
loadingPlayer.hide()
|
||||
if appearanceManager.has_method("request_remote_skin"):
|
||||
appearanceManager.call("request_remote_skin", skinId, skinAsset)
|
||||
return null
|
||||
if _remote_players.has(user_id):
|
||||
var existing_player := _remote_players[user_id] as Node2D
|
||||
if is_instance_valid(existing_player):
|
||||
_apply_remote_player_metadata(existing_player, data)
|
||||
existing_player.show()
|
||||
return existing_player
|
||||
|
||||
if _remote_players_root == null:
|
||||
return null
|
||||
|
||||
var remote_player := REMOTE_PLAYER_SCENE.instantiate() as Node2D
|
||||
if remote_player == null:
|
||||
return null
|
||||
|
||||
remote_player.name = "RemotePlayer_%s" % user_id
|
||||
remote_player.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
||||
_remote_players_root.add_child(remote_player)
|
||||
_remote_players[user_id] = remote_player
|
||||
|
||||
if remote_player.has_method("setup"):
|
||||
var setup_data := data.duplicate(true)
|
||||
if not setup_data.has("position"):
|
||||
setup_data["position"] = {"x": _local_player.global_position.x if _local_player != null else 0.0, "y": _local_player.global_position.y if _local_player != null else 0.0}
|
||||
remote_player.call("setup", setup_data)
|
||||
|
||||
return remote_player
|
||||
|
||||
func _queue_pending_remote_player(userId: String, data: Dictionary) -> void:
|
||||
var merged: Dictionary = (_pending_remote_players.get(userId, {}) as Dictionary).duplicate(true)
|
||||
for key in data.keys():
|
||||
merged[key] = data[key]
|
||||
_pending_remote_players[userId] = merged
|
||||
|
||||
func _on_remote_skin_ready(data: Dictionary) -> void:
|
||||
var skinId := str(data.get("skin_id", "")).strip_edges()
|
||||
if skinId.is_empty():
|
||||
return
|
||||
for userIdVariant in _pending_remote_players.keys().duplicate():
|
||||
var userId := str(userIdVariant)
|
||||
var pendingData: Dictionary = _pending_remote_players.get(userId, {})
|
||||
var pendingSkinId := str(pendingData.get("skin_id", pendingData.get("skinId", ""))).strip_edges()
|
||||
if pendingSkinId != skinId:
|
||||
continue
|
||||
_pending_remote_players.erase(userId)
|
||||
var remotePlayer := _ensure_remote_player(userId, pendingData)
|
||||
var positionVariant: Variant = pendingData.get("position", null)
|
||||
if remotePlayer != null and positionVariant is Vector2 and remotePlayer.has_method("update_position"):
|
||||
remotePlayer.call("update_position", positionVariant as Vector2)
|
||||
|
||||
func _on_remote_skin_failed(data: Dictionary) -> void:
|
||||
var skinId := str(data.get("skin_id", "")).strip_edges()
|
||||
if skinId.is_empty():
|
||||
return
|
||||
for userIdVariant in _pending_remote_players.keys().duplicate():
|
||||
var userId := str(userIdVariant)
|
||||
var pendingData: Dictionary = _pending_remote_players.get(userId, {})
|
||||
var pendingSkinId := str(pendingData.get("skin_id", pendingData.get("skinId", ""))).strip_edges()
|
||||
if pendingSkinId != skinId:
|
||||
continue
|
||||
_pending_remote_players.erase(userId)
|
||||
var fallbackData := pendingData.duplicate(true)
|
||||
fallbackData["skin_id"] = ""
|
||||
fallbackData["skinId"] = ""
|
||||
fallbackData.erase("skin_asset")
|
||||
fallbackData.erase("skinAsset")
|
||||
var remotePlayer := _ensure_remote_player(userId, fallbackData)
|
||||
var positionVariant: Variant = pendingData.get("position", null)
|
||||
if remotePlayer != null and positionVariant is Vector2 and remotePlayer.has_method("update_position"):
|
||||
remotePlayer.call("update_position", positionVariant as Vector2)
|
||||
|
||||
func _find_private_chat_target() -> Node2D:
|
||||
if _local_player == null or _remote_players.is_empty():
|
||||
return null
|
||||
|
||||
var local_position := _local_player.global_position
|
||||
var facing_direction := _get_local_facing_direction()
|
||||
var best_forward_target: Node2D = null
|
||||
var best_forward_distance := INF
|
||||
var best_any_target: Node2D = null
|
||||
var best_any_distance := INF
|
||||
|
||||
for remote_player_variant in _remote_players.values():
|
||||
var remote_player := remote_player_variant as Node2D
|
||||
if not is_instance_valid(remote_player):
|
||||
continue
|
||||
|
||||
var offset := remote_player.global_position - local_position
|
||||
var distance := offset.length()
|
||||
if distance > PRIVATE_CHAT_INTERACTION_DISTANCE:
|
||||
continue
|
||||
|
||||
if distance < best_any_distance:
|
||||
best_any_distance = distance
|
||||
best_any_target = remote_player
|
||||
|
||||
if distance <= 0.01:
|
||||
continue
|
||||
|
||||
var forward_dot := facing_direction.dot(offset.normalized())
|
||||
if forward_dot >= PRIVATE_CHAT_FORWARD_DOT_THRESHOLD and distance < best_forward_distance:
|
||||
best_forward_distance = distance
|
||||
best_forward_target = remote_player
|
||||
|
||||
return best_forward_target if best_forward_target != null else best_any_target
|
||||
|
||||
func _get_local_facing_direction() -> Vector2:
|
||||
if _local_player != null:
|
||||
var last_direction := str(_local_player.get("lastDirection"))
|
||||
match last_direction:
|
||||
"up":
|
||||
return Vector2.UP
|
||||
"down":
|
||||
return Vector2.DOWN
|
||||
"left":
|
||||
return Vector2.LEFT
|
||||
"right":
|
||||
return Vector2.RIGHT
|
||||
_:
|
||||
pass
|
||||
return Vector2.DOWN
|
||||
|
||||
func _apply_remote_player_metadata(remote_player: Node2D, data: Dictionary) -> void:
|
||||
var username := str(data.get("username", "")).strip_edges()
|
||||
if not username.is_empty():
|
||||
remote_player.set("username", username)
|
||||
if data.has("skin_id") or data.has("skinId") or data.has("skin_asset") or data.has("skinAsset") or data.has("avatar_id") or data.has("avatarId") or data.has("cafe_companion") or data.has("cafeCompanion"):
|
||||
if remote_player.has_method("setup"):
|
||||
remote_player.call("setup", data)
|
||||
|
||||
func _resolve_chat_bubble_target(data: Dictionary) -> Node2D:
|
||||
var from_user := str(data.get("from_user", data.get("username", ""))).strip_edges()
|
||||
var user_id := str(data.get("userId", data.get("user_id", ""))).strip_edges()
|
||||
var is_self := bool(data.get("is_self", false))
|
||||
|
||||
if is_self or (not user_id.is_empty() and _is_current_user(user_id, from_user)) or _is_current_username(from_user):
|
||||
return _local_player
|
||||
|
||||
if not user_id.is_empty() and _remote_players.has(user_id):
|
||||
var remote_by_id := _remote_players[user_id] as Node2D
|
||||
if is_instance_valid(remote_by_id):
|
||||
return remote_by_id
|
||||
|
||||
if from_user.is_empty():
|
||||
return null
|
||||
|
||||
for remote_player_variant in _remote_players.values():
|
||||
var remote_player := remote_player_variant as Node2D
|
||||
if not is_instance_valid(remote_player):
|
||||
continue
|
||||
var remote_username := str(remote_player.get("username")).strip_edges()
|
||||
if not remote_username.is_empty() and remote_username == from_user:
|
||||
return remote_player
|
||||
|
||||
return null
|
||||
|
||||
func _show_chat_bubble(target: Node2D, text: String) -> void:
|
||||
var bubble := CHAT_BUBBLE_SCENE.instantiate() as Control
|
||||
if bubble == null:
|
||||
return
|
||||
|
||||
var bubble_layer := _get_chat_bubble_layer()
|
||||
bubble_layer.add_child(bubble)
|
||||
if bubble.has_method("set_text"):
|
||||
bubble.call("set_text", text, target, CHAT_BUBBLE_TARGET_OFFSET)
|
||||
|
||||
func _get_chat_bubble_layer() -> CanvasLayer:
|
||||
return _get_or_create_canvas_layer(CHAT_BUBBLE_LAYER_NAME, 20)
|
||||
|
||||
func _get_or_create_canvas_layer(layerName: String, layerIndex: int) -> CanvasLayer:
|
||||
var root := get_tree().root
|
||||
var layer := root.get_node_or_null(layerName) as CanvasLayer
|
||||
if layer != null:
|
||||
return layer
|
||||
|
||||
layer = CanvasLayer.new()
|
||||
layer.name = layerName
|
||||
layer.layer = layerIndex
|
||||
root.add_child(layer)
|
||||
return layer
|
||||
|
||||
func _get_map_id() -> String:
|
||||
var configured_map_id := map_id.strip_edges()
|
||||
return configured_map_id if not configured_map_id.is_empty() else DEFAULT_MAP_ID
|
||||
|
||||
func _is_event_for_current_map(data: Dictionary) -> bool:
|
||||
var event_map_id := str(data.get("mapId", data.get("map_id", ""))).strip_edges()
|
||||
return event_map_id.is_empty() or event_map_id == _get_map_id()
|
||||
|
||||
func _is_current_user(user_id: String, username: String = "") -> bool:
|
||||
var authManager := get_node_or_null("/root/AuthManager")
|
||||
if authManager == null:
|
||||
return false
|
||||
|
||||
var user: Dictionary = authManager.call("get_current_user")
|
||||
var current_id := str(user.get("id", user.get("userId", user.get("user_id", "")))).strip_edges()
|
||||
if not current_id.is_empty() and user_id == current_id:
|
||||
return true
|
||||
|
||||
var current_username := str(user.get("username", "")).strip_edges()
|
||||
return not current_username.is_empty() and not username.strip_edges().is_empty() and current_username == username.strip_edges()
|
||||
|
||||
func _is_current_username(username: String) -> bool:
|
||||
var normalized_username := username.strip_edges()
|
||||
if normalized_username.is_empty():
|
||||
return false
|
||||
|
||||
var authManager := get_node_or_null("/root/AuthManager")
|
||||
if authManager != null:
|
||||
var user: Dictionary = authManager.call("get_current_user")
|
||||
var current_username := str(user.get("username", "")).strip_edges()
|
||||
if not current_username.is_empty() and current_username == normalized_username:
|
||||
return true
|
||||
|
||||
var chatManager := _get_chat_manager()
|
||||
if chatManager != null:
|
||||
var chat_username := str(chatManager.get("_current_username")).strip_edges()
|
||||
if not chat_username.is_empty() and chat_username == normalized_username:
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
func _settings_bool(key: String, defaultValue: bool) -> bool:
|
||||
var settingsManager := _get_settings_manager()
|
||||
if settingsManager != null and settingsManager.has_method("get_bool"):
|
||||
return bool(settingsManager.call("get_bool", key))
|
||||
return defaultValue
|
||||
1
scenes/Maps/MapMultiplayerController.gd.uid
Normal file
1
scenes/Maps/MapMultiplayerController.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dxupavbgcw3tw
|
||||
808
scenes/Maps/PersonalSpace.gd
Normal file
808
scenes/Maps/PersonalSpace.gd
Normal file
@@ -0,0 +1,808 @@
|
||||
class_name PersonalSpace
|
||||
extends Node2D
|
||||
|
||||
# ============================================================================
|
||||
# PersonalSpace.gd - 个人空间场景控制器
|
||||
# ============================================================================
|
||||
# 独立于广场的私人房间空间。当前版本负责固定镜头、玩家出生点和
|
||||
# 后续房间 manifest/装修系统的场景承载。
|
||||
# ============================================================================
|
||||
|
||||
const CAMERA_ZOOM: Vector2 = Vector2(1.65, 1.65)
|
||||
const CAMERA_LIMIT_LEFT: int = -538
|
||||
const CAMERA_LIMIT_TOP: int = -358
|
||||
const CAMERA_LIMIT_RIGHT: int = 538
|
||||
const CAMERA_LIMIT_BOTTOM: int = 358
|
||||
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
|
||||
|
||||
const TEXT_COLOR: Color = Color(0.102, 0.224, 0.376)
|
||||
const PANEL_COLOR: Color = Color(0.970, 0.992, 1.0, 0.96)
|
||||
const ACCENT_COLOR: Color = Color(0.086, 0.608, 0.922)
|
||||
const MUTED_COLOR: Color = Color(0.400, 0.500, 0.620)
|
||||
const DECOR_TEXTURE_FILTER: int = CanvasItem.TEXTURE_FILTER_LINEAR
|
||||
const DECOR_COLLISION_LAYER: int = 1
|
||||
const DECOR_COLLISION_MASK: int = 1
|
||||
|
||||
@onready var player: PlayerController = $YSortWorld/Characters/Players/Player
|
||||
@onready var playerCamera: Camera2D = $YSortWorld/Characters/Players/Player/Camera2D
|
||||
@onready var uiLayer: CanvasLayer = $UILayer
|
||||
@onready var ySortWorld: Node2D = $YSortWorld
|
||||
@onready var staticCollision: Node2D = $StaticCollision
|
||||
|
||||
var _decorLayer: Node2D
|
||||
var _inventoryRequest: HTTPRequest
|
||||
var _inventoryPanel: PanelContainer
|
||||
var _inventoryGrid: GridContainer
|
||||
var _dragSurface: Control
|
||||
var _toastLabel: Label
|
||||
var _selectedDecorId: String = ""
|
||||
var _draggedDecor: Sprite2D
|
||||
var _dragOffset: Vector2 = Vector2.ZERO
|
||||
var _decorItems: Dictionary = {}
|
||||
var _decorNodes: Dictionary = {}
|
||||
var _decorCollisionBodies: Dictionary = {}
|
||||
var _remoteDecorDefinitions: Dictionary = {}
|
||||
var _inventoryRequestInFlight: bool = false
|
||||
var _inventoryRequestReportsErrors: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
_leave_multiplayer_world()
|
||||
_apply_spawn_point()
|
||||
_configure_camera()
|
||||
_build_decor_layer()
|
||||
_build_room_decor_requests()
|
||||
_build_room_decor_ui()
|
||||
_connect_room_decor_events()
|
||||
_fetch_room_decor_inventory()
|
||||
set_process(false)
|
||||
|
||||
func _leave_multiplayer_world() -> void:
|
||||
var chatManager := get_node_or_null("/root/ChatManager")
|
||||
if chatManager != null and chatManager.has_method("leave_world"):
|
||||
chatManager.call("leave_world", "personal_space")
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var eventSystem := _get_event_system()
|
||||
if eventSystem != null:
|
||||
eventSystem.call("disconnect_event", EventNames.HUD_BACKPACK_TOGGLE, _on_backpack_toggle_requested, self)
|
||||
var saveManager := get_node_or_null("/root/RoomDecorSaveManager")
|
||||
if saveManager != null:
|
||||
if saveManager.decor_save_succeeded.is_connected(_on_decor_save_succeeded):
|
||||
saveManager.decor_save_succeeded.disconnect(_on_decor_save_succeeded)
|
||||
if saveManager.decor_save_failed.is_connected(_on_decor_save_failed):
|
||||
saveManager.decor_save_failed.disconnect(_on_decor_save_failed)
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton:
|
||||
var mouseEvent := event as InputEventMouseButton
|
||||
if mouseEvent.button_index != MOUSE_BUTTON_LEFT:
|
||||
return
|
||||
if _draggedDecor != null and not mouseEvent.pressed:
|
||||
_finish_decor_drag()
|
||||
get_viewport().set_input_as_handled()
|
||||
elif _draggedDecor == null and mouseEvent.pressed and not _is_pointer_over_blocking_ui():
|
||||
if _try_begin_decor_drag(get_global_mouse_position()):
|
||||
get_viewport().set_input_as_handled()
|
||||
elif event is InputEventMouseMotion and _draggedDecor != null:
|
||||
_update_dragged_decor_position()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _draggedDecor != null:
|
||||
_update_dragged_decor_position()
|
||||
|
||||
func _apply_spawn_point() -> void:
|
||||
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
|
||||
if marker == null:
|
||||
marker = $Markers/DefaultSpawn
|
||||
player.global_position = marker.global_position
|
||||
|
||||
func _configure_camera() -> void:
|
||||
playerCamera.zoom = CAMERA_ZOOM
|
||||
playerCamera.position_smoothing_enabled = true
|
||||
playerCamera.limit_left = CAMERA_LIMIT_LEFT
|
||||
playerCamera.limit_top = CAMERA_LIMIT_TOP
|
||||
playerCamera.limit_right = CAMERA_LIMIT_RIGHT
|
||||
playerCamera.limit_bottom = CAMERA_LIMIT_BOTTOM
|
||||
playerCamera.limit_smoothed = true
|
||||
|
||||
func _build_decor_layer() -> void:
|
||||
_decorLayer = Node2D.new()
|
||||
_decorLayer.name = "RoomDecorLayer"
|
||||
_decorLayer.y_sort_enabled = true
|
||||
ySortWorld.add_child(_decorLayer)
|
||||
ySortWorld.move_child(_decorLayer, 0)
|
||||
|
||||
func _build_room_decor_requests() -> void:
|
||||
_inventoryRequest = HTTPRequest.new()
|
||||
_inventoryRequest.name = "roomDecorInventoryRequest"
|
||||
_inventoryRequest.timeout = 12.0
|
||||
_inventoryRequest.request_completed.connect(_on_inventory_request_completed)
|
||||
add_child(_inventoryRequest)
|
||||
|
||||
func _build_room_decor_ui() -> void:
|
||||
_inventoryPanel = PanelContainer.new()
|
||||
_inventoryPanel.visible = false
|
||||
_inventoryPanel.custom_minimum_size = Vector2(560, 560)
|
||||
_inventoryPanel.offset_left = 24
|
||||
_inventoryPanel.offset_top = 116
|
||||
_inventoryPanel.offset_right = 584
|
||||
_inventoryPanel.offset_bottom = 676
|
||||
_inventoryPanel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_inventoryPanel.add_theme_stylebox_override("panel", _create_panel_style(PANEL_COLOR, 26, true))
|
||||
uiLayer.add_child(_inventoryPanel)
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 24)
|
||||
margin.add_theme_constant_override("margin_top", 22)
|
||||
margin.add_theme_constant_override("margin_right", 24)
|
||||
margin.add_theme_constant_override("margin_bottom", 22)
|
||||
_inventoryPanel.add_child(margin)
|
||||
|
||||
var root := VBoxContainer.new()
|
||||
root.add_theme_constant_override("separation", 14)
|
||||
margin.add_child(root)
|
||||
|
||||
var header := HBoxContainer.new()
|
||||
header.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
header.add_theme_constant_override("separation", 12)
|
||||
root.add_child(header)
|
||||
|
||||
var titleBox := VBoxContainer.new()
|
||||
titleBox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
titleBox.add_theme_constant_override("separation", 2)
|
||||
header.add_child(titleBox)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "背包"
|
||||
title.add_theme_font_size_override("font_size", 28)
|
||||
title.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
titleBox.add_child(title)
|
||||
|
||||
var hint := Label.new()
|
||||
hint.text = "按住装饰品,拖到房间里松手摆放。"
|
||||
hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
hint.add_theme_font_size_override("font_size", 16)
|
||||
hint.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
titleBox.add_child(hint)
|
||||
|
||||
var closeButton := Button.new()
|
||||
closeButton.text = "×"
|
||||
closeButton.custom_minimum_size = Vector2(48, 48)
|
||||
closeButton.focus_mode = Control.FOCUS_NONE
|
||||
closeButton.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
closeButton.add_theme_font_size_override("font_size", 30)
|
||||
closeButton.add_theme_color_override("font_color", Color.WHITE)
|
||||
closeButton.add_theme_stylebox_override("normal", _create_panel_style(ACCENT_COLOR, 18, false))
|
||||
closeButton.add_theme_stylebox_override("hover", _create_panel_style(Color(0.150, 0.680, 1.0, 1.0), 18, false))
|
||||
closeButton.add_theme_stylebox_override("pressed", _create_panel_style(Color(0.060, 0.420, 0.780, 1.0), 18, false))
|
||||
closeButton.pressed.connect(func() -> void:
|
||||
_inventoryPanel.visible = false
|
||||
)
|
||||
header.add_child(closeButton)
|
||||
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.custom_minimum_size = Vector2(0, 370)
|
||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
root.add_child(scroll)
|
||||
|
||||
_inventoryGrid = GridContainer.new()
|
||||
_inventoryGrid.columns = 2
|
||||
_inventoryGrid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_inventoryGrid.add_theme_constant_override("h_separation", 14)
|
||||
_inventoryGrid.add_theme_constant_override("v_separation", 14)
|
||||
scroll.add_child(_inventoryGrid)
|
||||
|
||||
var removeButton := Button.new()
|
||||
removeButton.text = "收回选中家具"
|
||||
removeButton.custom_minimum_size = Vector2(0, 46)
|
||||
removeButton.add_theme_font_size_override("font_size", 17)
|
||||
removeButton.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
removeButton.add_theme_color_override("font_hover_color", ACCENT_COLOR)
|
||||
removeButton.add_theme_color_override("font_pressed_color", Color(0.060, 0.270, 0.540, 1.0))
|
||||
removeButton.add_theme_color_override("font_disabled_color", Color(0.520, 0.600, 0.680, 0.55))
|
||||
removeButton.add_theme_stylebox_override("normal", _create_panel_style(Color(0.900, 0.960, 1.0, 0.92), 16, false))
|
||||
removeButton.add_theme_stylebox_override("hover", _create_panel_style(Color(0.820, 0.925, 1.0, 1.0), 16, false))
|
||||
removeButton.pressed.connect(_remove_selected_decor)
|
||||
root.add_child(removeButton)
|
||||
|
||||
_toastLabel = Label.new()
|
||||
_toastLabel.visible = false
|
||||
_toastLabel.offset_left = 360
|
||||
_toastLabel.offset_top = 100
|
||||
_toastLabel.offset_right = 760
|
||||
_toastLabel.offset_bottom = 148
|
||||
_toastLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_toastLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_toastLabel.add_theme_font_size_override("font_size", 18)
|
||||
_toastLabel.add_theme_color_override("font_color", Color.WHITE)
|
||||
_toastLabel.add_theme_stylebox_override("normal", _create_panel_style(Color(0.028, 0.160, 0.310, 0.90), 18, false))
|
||||
uiLayer.add_child(_toastLabel)
|
||||
|
||||
_dragSurface = Control.new()
|
||||
_dragSurface.name = "RoomDecorDragSurface"
|
||||
_dragSurface.visible = false
|
||||
_dragSurface.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_dragSurface.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_dragSurface.gui_input.connect(_on_drag_surface_gui_input)
|
||||
uiLayer.add_child(_dragSurface)
|
||||
|
||||
func _connect_room_decor_events() -> void:
|
||||
var eventSystem := _get_event_system()
|
||||
if eventSystem == null:
|
||||
push_warning("PersonalSpace: EventSystem autoload is not available.")
|
||||
else:
|
||||
eventSystem.call("connect_event", EventNames.HUD_BACKPACK_TOGGLE, _on_backpack_toggle_requested, self)
|
||||
var saveManager := get_node_or_null("/root/RoomDecorSaveManager")
|
||||
if saveManager != null:
|
||||
if not saveManager.decor_save_succeeded.is_connected(_on_decor_save_succeeded):
|
||||
saveManager.decor_save_succeeded.connect(_on_decor_save_succeeded)
|
||||
if not saveManager.decor_save_failed.is_connected(_on_decor_save_failed):
|
||||
saveManager.decor_save_failed.connect(_on_decor_save_failed)
|
||||
|
||||
func _on_backpack_toggle_requested(_data: Variant = null) -> void:
|
||||
_toggle_inventory_panel()
|
||||
|
||||
func _toggle_inventory_panel() -> void:
|
||||
_inventoryPanel.visible = not _inventoryPanel.visible
|
||||
if _inventoryPanel.visible:
|
||||
_fetch_room_decor_inventory(true)
|
||||
|
||||
func _fetch_room_decor_inventory(reportErrors: bool = false) -> void:
|
||||
if _inventoryRequestInFlight:
|
||||
_inventoryRequestReportsErrors = _inventoryRequestReportsErrors or reportErrors
|
||||
return
|
||||
if not _is_authenticated():
|
||||
_apply_local_empty_inventory()
|
||||
if reportErrors:
|
||||
_show_toast("请先登录后使用家具背包")
|
||||
return
|
||||
_inventoryRequestInFlight = true
|
||||
_inventoryRequestReportsErrors = reportErrors
|
||||
var err := _inventoryRequest.request("%s/rooms/me/decor-placements" % NetworkConfig.get_api_base_url(), _auth_headers(), HTTPClient.METHOD_GET, "")
|
||||
if err != OK:
|
||||
_inventoryRequestInFlight = false
|
||||
if _inventoryRequestReportsErrors:
|
||||
_show_toast("家具背包请求发送失败")
|
||||
_inventoryRequestReportsErrors = false
|
||||
|
||||
func _on_inventory_request_completed(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
_inventoryRequestInFlight = false
|
||||
var reportErrors := _inventoryRequestReportsErrors
|
||||
_inventoryRequestReportsErrors = false
|
||||
if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300:
|
||||
if reportErrors:
|
||||
_show_toast("家具背包读取失败")
|
||||
return
|
||||
var json := JSON.new()
|
||||
if json.parse(body.get_string_from_utf8()) != OK:
|
||||
if reportErrors:
|
||||
_show_toast("家具背包响应解析失败")
|
||||
return
|
||||
var responseVariant: Variant = json.data
|
||||
if not (responseVariant is Dictionary):
|
||||
if reportErrors:
|
||||
_show_toast("家具背包响应格式错误")
|
||||
return
|
||||
var response: Dictionary = responseVariant
|
||||
if not bool(response.get("success", true)):
|
||||
if reportErrors:
|
||||
_show_toast(str(response.get("message", "家具背包读取失败")))
|
||||
return
|
||||
var dataVariant: Variant = response.get("data", {})
|
||||
if dataVariant is Dictionary:
|
||||
_apply_inventory_payload(dataVariant as Dictionary)
|
||||
|
||||
func _apply_inventory_payload(data: Dictionary) -> void:
|
||||
_decorItems.clear()
|
||||
_apply_remote_decor_definitions(data.get("definitions", []))
|
||||
var itemsVariant: Variant = data.get("items", [])
|
||||
if itemsVariant is Array:
|
||||
for itemVariant in itemsVariant:
|
||||
if itemVariant is Dictionary:
|
||||
var item: Dictionary = (itemVariant as Dictionary).duplicate(true)
|
||||
var decorId := str(item.get("decor_id", "")).strip_edges()
|
||||
if not decorId.is_empty():
|
||||
_decorItems[decorId] = _merge_decor_definition(item)
|
||||
_render_inventory_list()
|
||||
_render_placed_decors()
|
||||
|
||||
func _apply_local_empty_inventory() -> void:
|
||||
_decorItems.clear()
|
||||
_render_inventory_list()
|
||||
_render_placed_decors()
|
||||
|
||||
func _merge_decor_definition(item: Dictionary) -> Dictionary:
|
||||
var decorId := str(item.get("decor_id", "")).strip_edges()
|
||||
var definition: Dictionary = _remoteDecorDefinitions.get(decorId, {})
|
||||
for key in definition.keys():
|
||||
if not item.has(key):
|
||||
item[key] = definition[key]
|
||||
return item
|
||||
|
||||
func _apply_remote_decor_definitions(definitionsVariant: Variant) -> void:
|
||||
_remoteDecorDefinitions.clear()
|
||||
if not (definitionsVariant is Array):
|
||||
return
|
||||
for definitionVariant in definitionsVariant:
|
||||
if not (definitionVariant is Dictionary):
|
||||
continue
|
||||
var definition: Dictionary = (definitionVariant as Dictionary).duplicate(true)
|
||||
var decorId := str(definition.get("decor_id", "")).strip_edges()
|
||||
if decorId.is_empty():
|
||||
continue
|
||||
if definition.get("default_position", null) is Dictionary:
|
||||
var position: Dictionary = definition.get("default_position")
|
||||
definition["default_position"] = Vector2(
|
||||
_to_float(position.get("x", 0.0), 0.0),
|
||||
_to_float(position.get("y", 0.0), 0.0)
|
||||
)
|
||||
_remoteDecorDefinitions[decorId] = definition
|
||||
|
||||
func _render_inventory_list() -> void:
|
||||
_clear_children(_inventoryGrid)
|
||||
if _decorItems.is_empty():
|
||||
var empty := _create_empty_inventory_card()
|
||||
_inventoryGrid.add_child(empty)
|
||||
return
|
||||
for decorId in _decorItems.keys():
|
||||
_inventoryGrid.add_child(_create_inventory_card(str(decorId)))
|
||||
|
||||
func _create_empty_inventory_card() -> Control:
|
||||
var card := PanelContainer.new()
|
||||
card.custom_minimum_size = Vector2(500, 132)
|
||||
card.add_theme_stylebox_override("panel", _create_panel_style(Color(1, 1, 1, 0.76), 20, false))
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 18)
|
||||
margin.add_theme_constant_override("margin_top", 18)
|
||||
margin.add_theme_constant_override("margin_right", 18)
|
||||
margin.add_theme_constant_override("margin_bottom", 18)
|
||||
card.add_child(margin)
|
||||
var label := Label.new()
|
||||
label.text = "背包里还没有装饰品。去商城的空间分类购买后会出现在这里。"
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
label.add_theme_font_size_override("font_size", 17)
|
||||
label.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
margin.add_child(label)
|
||||
return card
|
||||
|
||||
func _create_inventory_card(decorId: String) -> Button:
|
||||
var item: Dictionary = _decorItems[decorId]
|
||||
var card := Button.new()
|
||||
card.custom_minimum_size = Vector2(240, 176)
|
||||
card.focus_mode = Control.FOCUS_NONE
|
||||
card.mouse_default_cursor_shape = Control.CURSOR_DRAG
|
||||
card.add_theme_stylebox_override("normal", _create_inventory_card_style(false))
|
||||
card.add_theme_stylebox_override("hover", _create_inventory_card_style(true))
|
||||
card.add_theme_stylebox_override("pressed", _create_inventory_card_style(true))
|
||||
card.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
|
||||
card.button_down.connect(func() -> void:
|
||||
_begin_inventory_decor_drag(decorId)
|
||||
)
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
margin.add_theme_constant_override("margin_left", 12)
|
||||
margin.add_theme_constant_override("margin_top", 12)
|
||||
margin.add_theme_constant_override("margin_right", 12)
|
||||
margin.add_theme_constant_override("margin_bottom", 12)
|
||||
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
card.add_child(margin)
|
||||
|
||||
var content := VBoxContainer.new()
|
||||
content.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
content.add_theme_constant_override("separation", 6)
|
||||
content.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
margin.add_child(content)
|
||||
|
||||
var status := Label.new()
|
||||
status.custom_minimum_size = Vector2(0, 22)
|
||||
status.text = "已摆放" if bool(item.get("placed", false)) else "未摆放"
|
||||
status.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
status.add_theme_font_size_override("font_size", 14)
|
||||
status.add_theme_color_override("font_color", ACCENT_COLOR if bool(item.get("placed", false)) else MUTED_COLOR)
|
||||
status.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
content.add_child(status)
|
||||
|
||||
var icon := TextureRect.new()
|
||||
icon.texture = _load_texture(str(item.get("icon", "")))
|
||||
icon.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
|
||||
icon.custom_minimum_size = Vector2(0, 82)
|
||||
icon.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL
|
||||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
content.add_child(icon)
|
||||
|
||||
var nameLabel := Label.new()
|
||||
nameLabel.text = str(item.get("name", decorId))
|
||||
nameLabel.custom_minimum_size = Vector2(0, 36)
|
||||
nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
nameLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
nameLabel.add_theme_font_size_override("font_size", 17)
|
||||
nameLabel.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
nameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
content.add_child(nameLabel)
|
||||
|
||||
return card
|
||||
|
||||
func _render_placed_decors() -> void:
|
||||
for decorIdVariant in _decorNodes.keys().duplicate():
|
||||
var decorId := str(decorIdVariant)
|
||||
var item: Dictionary = _decorItems.get(decorId, {})
|
||||
if item.is_empty() or not bool(item.get("placed", false)):
|
||||
var node := _decorNodes[decorId] as Node
|
||||
if is_instance_valid(node):
|
||||
node.queue_free()
|
||||
_decorNodes.erase(decorId)
|
||||
_remove_decor_collision(decorId)
|
||||
for decorId in _decorItems.keys():
|
||||
var item: Dictionary = _decorItems[decorId]
|
||||
if bool(item.get("placed", false)):
|
||||
_ensure_decor_node(str(decorId), item)
|
||||
|
||||
func _begin_inventory_decor_drag(decorId: String) -> void:
|
||||
if not _decorItems.has(decorId):
|
||||
return
|
||||
_selectedDecorId = decorId
|
||||
var item := _ensure_decor_placed_for_drag(decorId)
|
||||
var node := _ensure_decor_node(decorId, item)
|
||||
if node == null:
|
||||
return
|
||||
_draggedDecor = node
|
||||
_dragOffset = Vector2.ZERO
|
||||
_set_decor_collision_disabled(decorId, true)
|
||||
_draggedDecor.z_index = max(_draggedDecor.z_index, 50)
|
||||
_inventoryPanel.visible = false
|
||||
_enable_drag_surface()
|
||||
_update_dragged_decor_position()
|
||||
_pulse_node(_draggedDecor)
|
||||
|
||||
func _ensure_decor_placed_for_drag(decorId: String) -> Dictionary:
|
||||
var item: Dictionary = _decorItems[decorId]
|
||||
if not bool(item.get("placed", false)):
|
||||
item["placed"] = true
|
||||
var defaultPosition := _variant_to_vector2(item.get("default_position", Vector2.ZERO))
|
||||
item["position_x"] = defaultPosition.x
|
||||
item["position_y"] = defaultPosition.y
|
||||
item["scale"] = _get_item_float(item, "default_scale", 1.0)
|
||||
item["z_index"] = _get_item_int(item, "default_z_index", 0)
|
||||
_decorItems[decorId] = item
|
||||
return item
|
||||
|
||||
func _ensure_decor_node(decorId: String, item: Dictionary) -> Sprite2D:
|
||||
var node := _decorNodes.get(decorId, null) as Sprite2D
|
||||
if node == null or not is_instance_valid(node):
|
||||
node = Sprite2D.new()
|
||||
node.name = "Decor_%s" % decorId
|
||||
node.texture_filter = DECOR_TEXTURE_FILTER
|
||||
node.centered = true
|
||||
node.set_meta("decor_id", decorId)
|
||||
_decorLayer.add_child(node)
|
||||
_decorNodes[decorId] = node
|
||||
else:
|
||||
node.texture_filter = DECOR_TEXTURE_FILTER
|
||||
node.texture = _load_texture(str(item.get("texture", item.get("icon", ""))))
|
||||
node.global_position = Vector2(_to_float(item.get("position_x", 0.0), 0.0), _to_float(item.get("position_y", 0.0), 0.0))
|
||||
var itemScale := _get_item_float(item, "scale", _get_item_float(item, "default_scale", 1.0))
|
||||
node.scale = Vector2(itemScale, itemScale)
|
||||
node.z_index = _get_item_int(item, "z_index", _get_item_int(item, "default_z_index", 0))
|
||||
_sync_decor_collision(decorId, item)
|
||||
return node
|
||||
|
||||
func _try_begin_decor_drag(worldPosition: Vector2) -> bool:
|
||||
var bestNode: Sprite2D = null
|
||||
var bestZ := -100000
|
||||
for decorId in _decorNodes.keys():
|
||||
var node := _decorNodes[decorId] as Sprite2D
|
||||
if node == null or not is_instance_valid(node) or node.texture == null:
|
||||
continue
|
||||
if not _is_point_inside_sprite(node, worldPosition):
|
||||
continue
|
||||
if node.z_index >= bestZ:
|
||||
bestNode = node
|
||||
bestZ = node.z_index
|
||||
if bestNode == null:
|
||||
return false
|
||||
_draggedDecor = bestNode
|
||||
_selectedDecorId = str(bestNode.get_meta("decor_id", ""))
|
||||
_dragOffset = bestNode.global_position - worldPosition
|
||||
_set_decor_collision_disabled(_selectedDecorId, true)
|
||||
_draggedDecor.z_index = max(_draggedDecor.z_index, 50)
|
||||
_enable_drag_surface()
|
||||
return true
|
||||
|
||||
func _finish_decor_drag() -> void:
|
||||
if _draggedDecor == null:
|
||||
_disable_drag_surface()
|
||||
return
|
||||
var decorId := str(_draggedDecor.get_meta("decor_id", ""))
|
||||
if _decorItems.has(decorId):
|
||||
var item: Dictionary = _decorItems[decorId]
|
||||
item["position_x"] = _draggedDecor.global_position.x
|
||||
item["position_y"] = _draggedDecor.global_position.y
|
||||
item["placed"] = true
|
||||
item["scale"] = _draggedDecor.scale.x
|
||||
item["z_index"] = _get_item_int(item, "default_z_index", 0)
|
||||
_decorItems[decorId] = item
|
||||
_draggedDecor.z_index = _get_item_int(item, "z_index", 0)
|
||||
_sync_decor_collision(decorId, item)
|
||||
_set_decor_collision_disabled(decorId, false)
|
||||
_save_decor_item(item)
|
||||
_render_inventory_list()
|
||||
_draggedDecor = null
|
||||
_disable_drag_surface()
|
||||
|
||||
func _update_dragged_decor_position() -> void:
|
||||
if _draggedDecor != null:
|
||||
_draggedDecor.global_position = get_global_mouse_position() + _dragOffset
|
||||
var decorId := str(_draggedDecor.get_meta("decor_id", ""))
|
||||
if not decorId.is_empty() and _decorItems.has(decorId):
|
||||
var item: Dictionary = _decorItems[decorId]
|
||||
item["position_x"] = _draggedDecor.global_position.x
|
||||
item["position_y"] = _draggedDecor.global_position.y
|
||||
_sync_decor_collision(decorId, item)
|
||||
|
||||
func _enable_drag_surface() -> void:
|
||||
if is_instance_valid(_dragSurface):
|
||||
_dragSurface.visible = true
|
||||
uiLayer.move_child(_dragSurface, uiLayer.get_child_count() - 1)
|
||||
set_process(true)
|
||||
|
||||
func _disable_drag_surface() -> void:
|
||||
if is_instance_valid(_dragSurface):
|
||||
_dragSurface.visible = false
|
||||
set_process(false)
|
||||
|
||||
func _on_drag_surface_gui_input(event: InputEvent) -> void:
|
||||
if _draggedDecor == null:
|
||||
_disable_drag_surface()
|
||||
return
|
||||
if event is InputEventMouseMotion:
|
||||
_update_dragged_decor_position()
|
||||
get_viewport().set_input_as_handled()
|
||||
elif event is InputEventMouseButton:
|
||||
var mouseEvent := event as InputEventMouseButton
|
||||
if mouseEvent.button_index == MOUSE_BUTTON_LEFT and not mouseEvent.pressed:
|
||||
_finish_decor_drag()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
func _is_pointer_over_blocking_ui() -> bool:
|
||||
var mousePosition := get_viewport().get_mouse_position()
|
||||
for child in uiLayer.get_children():
|
||||
if child == _dragSurface:
|
||||
continue
|
||||
if child is Control and _control_blocks_pointer(child as Control, mousePosition):
|
||||
return true
|
||||
return false
|
||||
|
||||
func _control_blocks_pointer(control: Control, mousePosition: Vector2) -> bool:
|
||||
if not control.visible or not control.is_visible_in_tree():
|
||||
return false
|
||||
if control.mouse_filter != Control.MOUSE_FILTER_IGNORE and control.get_global_rect().has_point(mousePosition):
|
||||
return true
|
||||
for child in control.get_children():
|
||||
if child is Control and _control_blocks_pointer(child as Control, mousePosition):
|
||||
return true
|
||||
return false
|
||||
|
||||
func _remove_selected_decor() -> void:
|
||||
if _selectedDecorId.is_empty() or not _decorItems.has(_selectedDecorId):
|
||||
_show_toast("请先选择一个家具")
|
||||
return
|
||||
var item: Dictionary = _decorItems[_selectedDecorId]
|
||||
item["placed"] = false
|
||||
_decorItems[_selectedDecorId] = item
|
||||
if _decorNodes.has(_selectedDecorId):
|
||||
var node := _decorNodes[_selectedDecorId] as Node
|
||||
if is_instance_valid(node):
|
||||
node.queue_free()
|
||||
_decorNodes.erase(_selectedDecorId)
|
||||
_remove_decor_collision(_selectedDecorId)
|
||||
_save_decor_item(item)
|
||||
_render_inventory_list()
|
||||
_show_toast("已收回家具")
|
||||
|
||||
func _save_decor_item(item: Dictionary) -> void:
|
||||
if not _is_authenticated():
|
||||
_show_toast("请先登录后保存摆放")
|
||||
return
|
||||
var saveManager := get_node_or_null("/root/RoomDecorSaveManager")
|
||||
if saveManager == null or not saveManager.has_method("enqueue_save"):
|
||||
_show_toast("家具保存服务不可用")
|
||||
return
|
||||
saveManager.call("enqueue_save", item)
|
||||
|
||||
func _on_decor_save_succeeded(savedItem: Dictionary) -> void:
|
||||
var item := _merge_decor_definition(savedItem.duplicate(true))
|
||||
var decorId := str(item.get("decor_id", ""))
|
||||
if not decorId.is_empty():
|
||||
_decorItems[decorId] = item
|
||||
|
||||
func _on_decor_save_failed(_item: Dictionary, message: String) -> void:
|
||||
_show_toast(message)
|
||||
|
||||
func _is_point_inside_sprite(node: Sprite2D, worldPosition: Vector2) -> bool:
|
||||
if node.texture == null:
|
||||
return false
|
||||
var localPoint := node.to_local(worldPosition)
|
||||
var size := node.texture.get_size()
|
||||
var rect := Rect2(-size * 0.5, size)
|
||||
return rect.has_point(localPoint)
|
||||
|
||||
func _sync_decor_collision(decorId: String, item: Dictionary) -> void:
|
||||
var collisionSize := _variant_to_vector2(item.get("collision_size", Vector2.ZERO))
|
||||
if collisionSize == Vector2.ZERO:
|
||||
_remove_decor_collision(decorId)
|
||||
return
|
||||
var collisionOffset := _variant_to_vector2(item.get("collision_offset", Vector2.ZERO))
|
||||
var body := _decorCollisionBodies.get(decorId, null) as StaticBody2D
|
||||
var shapeNode: CollisionShape2D
|
||||
if body == null or not is_instance_valid(body):
|
||||
body = StaticBody2D.new()
|
||||
body.name = "DecorCollision_%s" % decorId
|
||||
body.collision_layer = DECOR_COLLISION_LAYER
|
||||
body.collision_mask = DECOR_COLLISION_MASK
|
||||
shapeNode = CollisionShape2D.new()
|
||||
shapeNode.name = "CollisionShape2D"
|
||||
var rectShape := RectangleShape2D.new()
|
||||
shapeNode.shape = rectShape
|
||||
body.add_child(shapeNode)
|
||||
staticCollision.add_child(body)
|
||||
_decorCollisionBodies[decorId] = body
|
||||
else:
|
||||
shapeNode = body.get_node_or_null("CollisionShape2D") as CollisionShape2D
|
||||
if shapeNode == null:
|
||||
shapeNode = CollisionShape2D.new()
|
||||
shapeNode.name = "CollisionShape2D"
|
||||
shapeNode.shape = RectangleShape2D.new()
|
||||
body.add_child(shapeNode)
|
||||
var scale := _get_item_float(item, "scale", _get_item_float(item, "default_scale", 1.0))
|
||||
body.global_position = Vector2(_to_float(item.get("position_x", 0.0), 0.0), _to_float(item.get("position_y", 0.0), 0.0)) + collisionOffset * scale
|
||||
shapeNode.position = Vector2.ZERO
|
||||
var rectShape := shapeNode.shape as RectangleShape2D
|
||||
if rectShape == null:
|
||||
rectShape = RectangleShape2D.new()
|
||||
shapeNode.shape = rectShape
|
||||
rectShape.size = Vector2(max(8.0, collisionSize.x * scale), max(8.0, collisionSize.y * scale))
|
||||
|
||||
func _set_decor_collision_disabled(decorId: String, disabled: bool) -> void:
|
||||
var body := _decorCollisionBodies.get(decorId, null) as StaticBody2D
|
||||
if body == null or not is_instance_valid(body):
|
||||
return
|
||||
var shapeNode := body.get_node_or_null("CollisionShape2D") as CollisionShape2D
|
||||
if shapeNode != null:
|
||||
shapeNode.disabled = disabled
|
||||
|
||||
func _remove_decor_collision(decorId: String) -> void:
|
||||
var body := _decorCollisionBodies.get(decorId, null) as Node
|
||||
if body != null and is_instance_valid(body):
|
||||
body.queue_free()
|
||||
_decorCollisionBodies.erase(decorId)
|
||||
|
||||
func _variant_to_vector2(value: Variant) -> Vector2:
|
||||
if value is Vector2:
|
||||
return value
|
||||
if value is Dictionary:
|
||||
var dict: Dictionary = value
|
||||
return Vector2(_to_float(dict.get("x", 0.0), 0.0), _to_float(dict.get("y", 0.0), 0.0))
|
||||
return Vector2.ZERO
|
||||
|
||||
func _to_float(value: Variant, fallback: float) -> float:
|
||||
match typeof(value):
|
||||
TYPE_FLOAT:
|
||||
return value
|
||||
TYPE_INT:
|
||||
return value
|
||||
TYPE_STRING:
|
||||
var text := str(value).strip_edges()
|
||||
return fallback if text.is_empty() else text.to_float()
|
||||
TYPE_BOOL:
|
||||
return 1.0 if bool(value) else 0.0
|
||||
_:
|
||||
return fallback
|
||||
|
||||
func _to_int(value: Variant, fallback: int) -> int:
|
||||
match typeof(value):
|
||||
TYPE_INT:
|
||||
return value
|
||||
TYPE_FLOAT:
|
||||
return roundi(value)
|
||||
TYPE_STRING:
|
||||
var text := str(value).strip_edges()
|
||||
return fallback if text.is_empty() else text.to_int()
|
||||
TYPE_BOOL:
|
||||
return 1 if bool(value) else 0
|
||||
_:
|
||||
return fallback
|
||||
|
||||
func _get_item_float(item: Dictionary, key: String, fallback: float) -> float:
|
||||
return _to_float(item.get(key, fallback), fallback)
|
||||
|
||||
func _get_item_int(item: Dictionary, key: String, fallback: int) -> int:
|
||||
return _to_int(item.get(key, fallback), fallback)
|
||||
|
||||
func _is_authenticated() -> bool:
|
||||
var authManager := get_node_or_null("/root/AuthManager")
|
||||
return authManager != null and authManager.has_method("is_authenticated") and bool(authManager.call("is_authenticated"))
|
||||
|
||||
func _auth_headers() -> PackedStringArray:
|
||||
var authManager := get_node_or_null("/root/AuthManager")
|
||||
var accessToken := str(authManager.call("get_access_token")).strip_edges() if authManager != null and authManager.has_method("get_access_token") else ""
|
||||
return PackedStringArray([
|
||||
"Content-Type: application/json",
|
||||
"Authorization: Bearer %s" % accessToken,
|
||||
])
|
||||
|
||||
func _load_texture(path: String) -> Texture2D:
|
||||
if path.is_empty():
|
||||
return null
|
||||
if FileAccess.file_exists("%s.import" % path):
|
||||
var texture := load(path) as Texture2D
|
||||
if texture != null:
|
||||
return texture
|
||||
var image := Image.load_from_file(ProjectSettings.globalize_path(path))
|
||||
if image == null or image.is_empty():
|
||||
return null
|
||||
return ImageTexture.create_from_image(image)
|
||||
|
||||
func _get_event_system() -> Node:
|
||||
return get_node_or_null("/root/EventSystem")
|
||||
|
||||
func _show_toast(message: String) -> void:
|
||||
if not is_instance_valid(_toastLabel):
|
||||
return
|
||||
_toastLabel.text = message
|
||||
_toastLabel.visible = true
|
||||
var tween := create_tween()
|
||||
tween.tween_interval(1.5)
|
||||
tween.tween_callback(func() -> void:
|
||||
if is_instance_valid(_toastLabel):
|
||||
_toastLabel.visible = false
|
||||
)
|
||||
|
||||
func _pulse_node(node: Node2D) -> void:
|
||||
var originalScale := node.scale
|
||||
var tween := create_tween()
|
||||
tween.tween_property(node, "scale", originalScale * 1.08, 0.08)
|
||||
tween.tween_property(node, "scale", originalScale, 0.10)
|
||||
|
||||
func _clear_children(node: Node) -> void:
|
||||
for child in node.get_children():
|
||||
child.queue_free()
|
||||
|
||||
func _create_panel_style(color: Color, radius: int, shadow: bool) -> 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.content_margin_left = 12
|
||||
style.content_margin_top = 8
|
||||
style.content_margin_right = 12
|
||||
style.content_margin_bottom = 8
|
||||
if shadow:
|
||||
style.shadow_color = Color(0.082, 0.243, 0.380, 0.18)
|
||||
style.shadow_size = 12
|
||||
style.shadow_offset = Vector2(0, 4)
|
||||
return style
|
||||
|
||||
func _create_inventory_card_style(hovered: bool) -> StyleBoxFlat:
|
||||
var style := _create_panel_style(Color(0.982, 0.995, 1.0, 1.0), 18, true)
|
||||
style.border_width_left = 2
|
||||
style.border_width_top = 2
|
||||
style.border_width_right = 2
|
||||
style.border_width_bottom = 2
|
||||
style.border_color = Color(0.650, 0.835, 0.965, 0.90) if not hovered else Color(0.086, 0.690, 0.960, 1.0)
|
||||
style.shadow_color = Color(0.086, 0.314, 0.520, 0.14 if hovered else 0.08)
|
||||
style.shadow_size = 14 if hovered else 8
|
||||
style.content_margin_left = 0
|
||||
style.content_margin_top = 0
|
||||
style.content_margin_right = 0
|
||||
style.content_margin_bottom = 0
|
||||
return style
|
||||
1
scenes/Maps/PersonalSpace.gd.uid
Normal file
1
scenes/Maps/PersonalSpace.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c1ndorvawxclg
|
||||
32
scenes/Maps/ScenePortal.gd
Normal file
32
scenes/Maps/ScenePortal.gd
Normal file
@@ -0,0 +1,32 @@
|
||||
class_name ScenePortal
|
||||
extends Area2D
|
||||
|
||||
# ============================================================================
|
||||
# ScenePortal.gd - 场景入口传送组件
|
||||
# ============================================================================
|
||||
# 提供轻量级场景跳转能力,用于广场、小区和个人空间之间的入口。
|
||||
# ============================================================================
|
||||
|
||||
@export var targetSceneName: String = ""
|
||||
@export var targetSpawnName: String = ""
|
||||
@export var targetPosition: Vector2 = Vector2.ZERO
|
||||
|
||||
func _ready() -> void:
|
||||
body_entered.connect(_on_body_entered)
|
||||
|
||||
func _on_body_entered(body: Node2D) -> void:
|
||||
if not (body is PlayerController):
|
||||
return
|
||||
_change_scene()
|
||||
|
||||
func _change_scene() -> void:
|
||||
if targetSceneName.is_empty():
|
||||
push_warning("ScenePortal: targetSceneName is empty.")
|
||||
return
|
||||
|
||||
if not targetSpawnName.is_empty():
|
||||
SceneManager.set_next_spawn_name(targetSpawnName)
|
||||
elif targetPosition != Vector2.ZERO:
|
||||
SceneManager.set_next_scene_position(targetPosition)
|
||||
|
||||
SceneManager.change_scene(targetSceneName)
|
||||
1
scenes/Maps/ScenePortal.gd.uid
Normal file
1
scenes/Maps/ScenePortal.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dr6uk7m4fsr4l
|
||||
42
scenes/Maps/Square.gd
Normal file
42
scenes/Maps/Square.gd
Normal file
@@ -0,0 +1,42 @@
|
||||
class_name Square
|
||||
extends Node2D
|
||||
|
||||
# ============================================================================
|
||||
# Square.gd - 广场场景控制器
|
||||
# ============================================================================
|
||||
# 负责广场玩家出生点和玩家相机边界配置,避免运行时看到地图外区域。
|
||||
# ============================================================================
|
||||
|
||||
const CAMERA_ZOOM: Vector2 = Vector2(2.0, 2.0)
|
||||
const CAMERA_LIMIT_LEFT: int = -1280
|
||||
const CAMERA_LIMIT_TOP: int = -960
|
||||
const CAMERA_LIMIT_RIGHT: int = 1280
|
||||
const CAMERA_LIMIT_BOTTOM: int = 960
|
||||
|
||||
@onready var player: PlayerController = $YSortWorld/Characters/Players/Player
|
||||
@onready var playerCamera: Camera2D = $YSortWorld/Characters/Players/Player/Camera2D
|
||||
@onready var overviewCamera: Camera2D = $Camera2D
|
||||
|
||||
func _ready() -> void:
|
||||
_apply_spawn_point()
|
||||
_configure_camera()
|
||||
|
||||
func _apply_spawn_point() -> void:
|
||||
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
|
||||
if marker == null:
|
||||
marker = $Markers/DefaultSpawn
|
||||
player.global_position = marker.global_position
|
||||
|
||||
func _configure_camera() -> void:
|
||||
overviewCamera.enabled = false
|
||||
playerCamera.enabled = true
|
||||
playerCamera.make_current()
|
||||
playerCamera.zoom = CAMERA_ZOOM
|
||||
playerCamera.position_smoothing_enabled = true
|
||||
playerCamera.limit_left = CAMERA_LIMIT_LEFT
|
||||
playerCamera.limit_top = CAMERA_LIMIT_TOP
|
||||
playerCamera.limit_right = CAMERA_LIMIT_RIGHT
|
||||
playerCamera.limit_bottom = CAMERA_LIMIT_BOTTOM
|
||||
playerCamera.limit_smoothed = true
|
||||
1
scenes/Maps/Square.gd.uid
Normal file
1
scenes/Maps/Square.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://biu0igl6133q5
|
||||
176
scenes/Maps/WorkZone.gd
Normal file
176
scenes/Maps/WorkZone.gd
Normal file
@@ -0,0 +1,176 @@
|
||||
class_name WorkZone
|
||||
extends Node2D
|
||||
|
||||
# ============================================================================
|
||||
# WorkZone.gd - 打工区场景控制器
|
||||
# ============================================================================
|
||||
# 承载赛博打工区概念地图,负责玩家出生点和相机边界配置。
|
||||
# ============================================================================
|
||||
|
||||
const CAMERA_ZOOM: Vector2 = Vector2(2.0, 2.0)
|
||||
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 MALL_PANEL_SCENE: PackedScene = preload("res://scenes/ui/mall/MallPanel.tscn")
|
||||
const COURSE_BOARD_PANEL_SCENE: PackedScene = preload("res://scenes/ui/CourseBoardPanel.tscn")
|
||||
const MALL_ENTRANCE_POSITION: Vector2 = Vector2(-4, -418)
|
||||
const MALL_ENTRANCE_SIZE: Vector2 = Vector2(112, 34)
|
||||
const MALL_EXIT_POSITION: Vector2 = Vector2(-4, -310)
|
||||
const MALL_EXIT_REOPEN_COOLDOWN: float = 0.45
|
||||
const PLAYER_COLLISION_LAYER: int = 1
|
||||
|
||||
@onready var player: PlayerController = $YSortWorld/Characters/Players/Player
|
||||
@onready var playerCamera: Camera2D = $YSortWorld/Characters/Players/Player/Camera2D
|
||||
@onready var uiLayer: CanvasLayer = $UILayer
|
||||
|
||||
var _mallPanel: Control
|
||||
var _courseBoardPanel: Control
|
||||
var _mallEntranceArea: Area2D
|
||||
var _isInsideMall: bool = false
|
||||
var _mallCanEnter: bool = true
|
||||
|
||||
func _ready() -> void:
|
||||
_apply_spawn_point()
|
||||
_configure_camera()
|
||||
_ensure_mall_panel()
|
||||
_ensure_course_board_panel()
|
||||
_ensure_mall_entrance_area()
|
||||
EventSystem.connect_event(EventNames.OBJECT_INTERACTED, _on_object_interacted, self)
|
||||
EventSystem.connect_event(EventNames.MALL_CLOSED, _on_mall_closed, self)
|
||||
|
||||
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:
|
||||
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
|
||||
if marker == null:
|
||||
marker = $Markers/DefaultSpawn
|
||||
player.global_position = marker.global_position
|
||||
|
||||
func _configure_camera() -> void:
|
||||
playerCamera.zoom = CAMERA_ZOOM
|
||||
playerCamera.position_smoothing_enabled = true
|
||||
playerCamera.limit_left = CAMERA_LIMIT_LEFT
|
||||
playerCamera.limit_top = CAMERA_LIMIT_TOP
|
||||
playerCamera.limit_right = CAMERA_LIMIT_RIGHT
|
||||
playerCamera.limit_bottom = CAMERA_LIMIT_BOTTOM
|
||||
playerCamera.limit_smoothed = true
|
||||
|
||||
func _ensure_mall_panel() -> void:
|
||||
var existingPanel := uiLayer.get_node_or_null("MallPanel") as Control
|
||||
if existingPanel != null:
|
||||
_mallPanel = existingPanel
|
||||
return
|
||||
_mallPanel = MALL_PANEL_SCENE.instantiate() as Control
|
||||
_mallPanel.name = "MallPanel"
|
||||
uiLayer.add_child(_mallPanel)
|
||||
|
||||
func _ensure_course_board_panel() -> void:
|
||||
var existingPanel := uiLayer.get_node_or_null("CourseBoardPanel") as Control
|
||||
if existingPanel != null:
|
||||
_courseBoardPanel = existingPanel
|
||||
return
|
||||
_courseBoardPanel = COURSE_BOARD_PANEL_SCENE.instantiate() as Control
|
||||
_courseBoardPanel.name = "CourseBoardPanel"
|
||||
uiLayer.add_child(_courseBoardPanel)
|
||||
|
||||
func _ensure_mall_entrance_area() -> void:
|
||||
var areas := get_node_or_null("InteractionAreas") as Node2D
|
||||
if areas == null:
|
||||
areas = Node2D.new()
|
||||
areas.name = "InteractionAreas"
|
||||
add_child(areas)
|
||||
|
||||
var entrance := areas.get_node_or_null("MallEntranceArea") as WorkZoneBuildingArea
|
||||
if entrance == null:
|
||||
entrance = WorkZoneBuildingArea.new()
|
||||
entrance.name = "MallEntranceArea"
|
||||
areas.add_child(entrance)
|
||||
|
||||
entrance.buildingId = "whale_super_mall"
|
||||
entrance.buildingTitle = "鲸鱼商城"
|
||||
entrance.buildingRole = "mall"
|
||||
entrance.position = MALL_ENTRANCE_POSITION
|
||||
|
||||
var shape := entrance.get_node_or_null("CollisionShape2D") as CollisionShape2D
|
||||
if shape == null:
|
||||
shape = CollisionShape2D.new()
|
||||
shape.name = "CollisionShape2D"
|
||||
entrance.add_child(shape)
|
||||
|
||||
var rectangle := shape.shape as RectangleShape2D
|
||||
if rectangle == null:
|
||||
rectangle = RectangleShape2D.new()
|
||||
shape.shape = rectangle
|
||||
shape.position = Vector2.ZERO
|
||||
rectangle.size = MALL_ENTRANCE_SIZE
|
||||
_setup_mall_entrance_trigger(entrance)
|
||||
|
||||
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)
|
||||
|
||||
func _on_object_interacted(data: Dictionary) -> void:
|
||||
var buildingId := str(data.get("buildingId", ""))
|
||||
if buildingId.is_empty():
|
||||
return
|
||||
if buildingId == "whale_super_mall":
|
||||
return
|
||||
if buildingId == "virtual_whale_recruitment_board":
|
||||
_open_course_board()
|
||||
return
|
||||
if not [
|
||||
"whale_job_center",
|
||||
"ai_service_station",
|
||||
"whale_coin_exchange",
|
||||
"virtual_whale_recruitment_board",
|
||||
].has(buildingId):
|
||||
return
|
||||
|
||||
var title := str(data.get("title", buildingId))
|
||||
var role := str(data.get("role", ""))
|
||||
print("WorkZone interaction ready: %s (%s)" % [title, role])
|
||||
|
||||
func _open_course_board() -> void:
|
||||
if is_instance_valid(_courseBoardPanel) and _courseBoardPanel.has_method("show_panel"):
|
||||
_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:
|
||||
return
|
||||
_isInsideMall = true
|
||||
_set_player_mall_transition_state(true)
|
||||
if is_instance_valid(_mallPanel) and _mallPanel.has_method("show_panel"):
|
||||
_mallPanel.call("show_panel")
|
||||
|
||||
func _on_mall_closed(_data: Dictionary = {}) -> void:
|
||||
if not _isInsideMall:
|
||||
return
|
||||
_isInsideMall = false
|
||||
_mallCanEnter = false
|
||||
player.global_position = MALL_EXIT_POSITION
|
||||
if player.has_method("_reset_movement_input_state"):
|
||||
player.call("_reset_movement_input_state")
|
||||
_set_player_mall_transition_state(false)
|
||||
await get_tree().create_timer(MALL_EXIT_REOPEN_COOLDOWN).timeout
|
||||
_mallCanEnter = true
|
||||
|
||||
func _set_player_mall_transition_state(isInsideMall: bool) -> void:
|
||||
if not is_instance_valid(player):
|
||||
return
|
||||
player.visible = not isInsideMall
|
||||
player.set_physics_process(not isInsideMall)
|
||||
player.velocity = Vector2.ZERO
|
||||
if player.has_method("_reset_movement_input_state"):
|
||||
player.call("_reset_movement_input_state")
|
||||
1
scenes/Maps/WorkZone.gd.uid
Normal file
1
scenes/Maps/WorkZone.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://6de5gvosic2s
|
||||
30
scenes/Maps/WorkZoneBuildingArea.gd
Normal file
30
scenes/Maps/WorkZoneBuildingArea.gd
Normal file
@@ -0,0 +1,30 @@
|
||||
class_name WorkZoneBuildingArea
|
||||
extends Area2D
|
||||
|
||||
# ============================================================================
|
||||
# WorkZoneBuildingArea.gd - 打工区建筑交互入口
|
||||
# ============================================================================
|
||||
# 为打工区建筑提供统一交互数据,玩家面对入口按交互键时由 PlayerController 调用。
|
||||
# ============================================================================
|
||||
|
||||
const INTERACTION_COLLISION_LAYER: int = 2
|
||||
|
||||
@export var buildingId: String = ""
|
||||
@export var buildingTitle: String = ""
|
||||
@export var buildingRole: String = ""
|
||||
|
||||
func _ready() -> void:
|
||||
collision_layer = INTERACTION_COLLISION_LAYER
|
||||
collision_mask = 0
|
||||
|
||||
func interact() -> void:
|
||||
var payload := {
|
||||
"buildingId": buildingId,
|
||||
"title": buildingTitle,
|
||||
"role": buildingRole,
|
||||
"nodePath": get_path(),
|
||||
}
|
||||
var eventSystem := get_node_or_null("/root/EventSystem")
|
||||
if eventSystem != null:
|
||||
eventSystem.call("emit_event", EventNames.OBJECT_INTERACTED, payload)
|
||||
print("WorkZone building interacted: %s - %s" % [buildingTitle, buildingRole])
|
||||
1
scenes/Maps/WorkZoneBuildingArea.gd.uid
Normal file
1
scenes/Maps/WorkZoneBuildingArea.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c6catye0hyjwp
|
||||
360
scenes/Maps/cafe_interior.tscn
Normal file
360
scenes/Maps/cafe_interior.tscn
Normal file
@@ -0,0 +1,360 @@
|
||||
[gd_scene format=3 uid="uid://c2flhkqk5icab"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://ccesagjisqodl" path="res://scenes/Maps/CafeInterior.gd" id="1_script"]
|
||||
[ext_resource type="Texture2D" path="res://output/novamailio-imagegen/cafe_interior_scene/whale_cafe_large_service_hall_base_v4.png" id="2_map"]
|
||||
[ext_resource type="PackedScene" uid="uid://b2f8e24plwqgj" path="res://scenes/characters/player.tscn" id="3_player"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/ui/ChatUI.tscn" id="4_chatui"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/ui/PlayerHud.tscn" id="5_playerhud"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/ui/FriendListPanel.tscn" id="6_friendpanel"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/ui/SettingsPanel.tscn" id="7_settings"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/characters/cafe_whale_barista_npc.tscn" id="8_barista_npc"]
|
||||
[ext_resource type="Script" uid="uid://d1sfqui0pqalf" path="res://scenes/characters/CafeCompanionTarget.gd" id="9_cafe_companion_target"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/ui/CafeCompanionPanel.tscn" id="10_cafe_companion_panel"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/ui/CafeCompanionRecruitmentPanel.tscn" id="11_cafe_recruitment_panel"]
|
||||
[ext_resource type="Script" uid="uid://dxupavbgcw3tw" path="res://scenes/Maps/MapMultiplayerController.gd" id="12_multiplayer"]
|
||||
[ext_resource type="Theme" uid="uid://brk6ca2npglqc" path="res://assets/ui/world_text_theme.tres" id="13_world_text_theme"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/ui/MapPanel.tscn" id="14_mappanel"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="Style_CafeCompanionNameplate"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 4.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 4.0
|
||||
bg_color = Color(1, 0.988, 0.955, 0.96)
|
||||
border_width_left = 2
|
||||
border_width_top = 2
|
||||
border_width_right = 2
|
||||
border_width_bottom = 2
|
||||
border_color = Color(0.18, 0.34, 0.38, 0.92)
|
||||
corner_radius_top_left = 12
|
||||
corner_radius_top_right = 12
|
||||
corner_radius_bottom_right = 12
|
||||
corner_radius_bottom_left = 12
|
||||
shadow_color = Color(0.05, 0.08, 0.09, 0.18)
|
||||
shadow_size = 4
|
||||
shadow_offset = Vector2(0, 2)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="Shape_BaristaChatTarget"]
|
||||
size = Vector2(112, 148)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="Shape_TopWall"]
|
||||
size = Vector2(1580, 72)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="Shape_BottomWall"]
|
||||
size = Vector2(1572, 72)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="Shape_LeftWall"]
|
||||
size = Vector2(80, 1100)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="Shape_RightWall"]
|
||||
size = Vector2(47, 721)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="Shape_FrontCounterBand"]
|
||||
size = Vector2(1063, 64)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="Shape_BackEquipmentBand"]
|
||||
size = Vector2(1300, 92)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="Shape_LeftServiceFixture"]
|
||||
size = Vector2(208, 309)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="Shape_RightServiceFixture"]
|
||||
size = Vector2(202, 329)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="Shape_ExitArea"]
|
||||
size = Vector2(420, 96)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="Shape_CafeRecruitmentLogo"]
|
||||
size = Vector2(135, 63)
|
||||
|
||||
[node name="CafeInterior" type="Node2D" unique_id=6364941]
|
||||
script = ExtResource("1_script")
|
||||
|
||||
[node name="UILayer" type="CanvasLayer" parent="." unique_id=915800518]
|
||||
layer = 10
|
||||
|
||||
[node name="ChatUI" parent="UILayer" unique_id=1173101039 instance=ExtResource("4_chatui")]
|
||||
|
||||
[node name="PlayerHud" parent="UILayer" unique_id=1564092911 instance=ExtResource("5_playerhud")]
|
||||
|
||||
[node name="MapPanel" parent="UILayer" instance=ExtResource("14_mappanel")]
|
||||
|
||||
[node name="FriendListPanel" parent="UILayer" unique_id=916180939 instance=ExtResource("6_friendpanel")]
|
||||
|
||||
[node name="SettingsPanel" parent="UILayer" unique_id=783599923 instance=ExtResource("7_settings")]
|
||||
|
||||
[node name="CafeCompanionPanel" parent="UILayer" unique_id=365240236 instance=ExtResource("10_cafe_companion_panel")]
|
||||
|
||||
[node name="CafeCompanionRecruitmentPanel" parent="UILayer" unique_id=1274369176 instance=ExtResource("11_cafe_recruitment_panel")]
|
||||
|
||||
[node name="StageBackdrop" type="ColorRect" parent="." unique_id=120901466]
|
||||
z_index = -200
|
||||
offset_left = -2000.0
|
||||
offset_top = -1600.0
|
||||
offset_right = 2000.0
|
||||
offset_bottom = 1600.0
|
||||
mouse_filter = 2
|
||||
color = Color(0.065, 0.075, 0.09, 1)
|
||||
|
||||
[node name="CafeServiceHallBase" type="Sprite2D" parent="." unique_id=106640887]
|
||||
z_index = -100
|
||||
texture_filter = 1
|
||||
texture = ExtResource("2_map")
|
||||
|
||||
[node name="YSortWorld" type="Node2D" parent="." unique_id=1472764725]
|
||||
y_sort_enabled = true
|
||||
|
||||
[node name="Characters" type="Node2D" parent="YSortWorld" unique_id=2141328072]
|
||||
y_sort_enabled = true
|
||||
|
||||
[node name="Players" type="Node2D" parent="YSortWorld/Characters" unique_id=1853391519]
|
||||
y_sort_enabled = true
|
||||
|
||||
[node name="Player" parent="YSortWorld/Characters/Players" unique_id=616484753 instance=ExtResource("3_player")]
|
||||
position = Vector2(0, 325)
|
||||
|
||||
[node name="CafeHiredCompanionNameplate" type="Label" parent="YSortWorld/Characters/Players/Player" unique_id=1594219674]
|
||||
visible = false
|
||||
z_index = 30
|
||||
custom_minimum_size = Vector2(188, 44)
|
||||
offset_left = -47.0
|
||||
offset_top = -96.0
|
||||
offset_right = -47.0
|
||||
offset_bottom = -96.0
|
||||
scale = Vector2(0.5, 0.5)
|
||||
theme = ExtResource("13_world_text_theme")
|
||||
theme_override_colors/font_color = Color(0.12, 0.2, 0.24, 1)
|
||||
theme_override_colors/font_shadow_color = Color(1, 1, 1, 0.85)
|
||||
theme_override_constants/shadow_offset_x = 0
|
||||
theme_override_constants/shadow_offset_y = 1
|
||||
theme_override_font_sizes/font_size = 24
|
||||
theme_override_styles/normal = SubResource("Style_CafeCompanionNameplate")
|
||||
text = "陪伴机器人"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="CafeHiredCompanionTarget" type="Area2D" parent="YSortWorld/Characters/Players/Player" unique_id=1604517960]
|
||||
position = Vector2(0, -58)
|
||||
collision_layer = 2
|
||||
collision_mask = 0
|
||||
script = ExtResource("9_cafe_companion_target")
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="YSortWorld/Characters/Players/Player/CafeHiredCompanionTarget" unique_id=658877351]
|
||||
shape = SubResource("Shape_BaristaChatTarget")
|
||||
disabled = true
|
||||
|
||||
[node name="RemotePlayers" type="Node2D" parent="YSortWorld/Characters/Players" unique_id=2071839484]
|
||||
y_sort_enabled = true
|
||||
|
||||
[node name="CafeWhaleBaristaNpc" parent="YSortWorld/Characters" unique_id=1188569491 instance=ExtResource("8_barista_npc")]
|
||||
position = Vector2(-472, -291)
|
||||
|
||||
[node name="CafeCompanionNameplate" type="Label" parent="YSortWorld/Characters/CafeWhaleBaristaNpc" unique_id=885952340]
|
||||
z_index = 30
|
||||
custom_minimum_size = Vector2(152, 44)
|
||||
offset_left = -38.0
|
||||
offset_top = -136.0
|
||||
offset_right = -38.0
|
||||
offset_bottom = -136.0
|
||||
scale = Vector2(0.5, 0.5)
|
||||
theme = ExtResource("13_world_text_theme")
|
||||
theme_override_colors/font_color = Color(0.12, 0.2, 0.25, 1)
|
||||
theme_override_colors/font_shadow_color = Color(1, 1, 1, 0.85)
|
||||
theme_override_constants/shadow_offset_x = 0
|
||||
theme_override_constants/shadow_offset_y = 1
|
||||
theme_override_font_sizes/font_size = 24
|
||||
theme_override_styles/normal = SubResource("Style_CafeCompanionNameplate")
|
||||
text = "海盐拿铁"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="CafeCompanionTarget" type="Area2D" parent="YSortWorld/Characters/CafeWhaleBaristaNpc" unique_id=1439390611]
|
||||
position = Vector2(0, -58)
|
||||
script = ExtResource("9_cafe_companion_target")
|
||||
servicePointId = "ServiceIdlePoint01"
|
||||
companionId = "cafe_companion_npc"
|
||||
personaName = "海盐拿铁"
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="YSortWorld/Characters/CafeWhaleBaristaNpc/CafeCompanionTarget" unique_id=292882204]
|
||||
shape = SubResource("Shape_BaristaChatTarget")
|
||||
|
||||
[node name="MultiplayerController" type="Node" parent="YSortWorld" unique_id=1171758081]
|
||||
script = ExtResource("12_multiplayer")
|
||||
map_id = "whale_cafe"
|
||||
|
||||
[node name="StaticCollision" type="Node2D" parent="." unique_id=329016366]
|
||||
|
||||
[node name="CafeBoundary" type="StaticBody2D" parent="StaticCollision" unique_id=810981314]
|
||||
|
||||
[node name="TopWall" type="CollisionShape2D" parent="StaticCollision/CafeBoundary" unique_id=83767543]
|
||||
position = Vector2(14, -501)
|
||||
shape = SubResource("Shape_TopWall")
|
||||
|
||||
[node name="BottomWall" type="CollisionShape2D" parent="StaticCollision/CafeBoundary" unique_id=578059512]
|
||||
position = Vector2(3, 472)
|
||||
shape = SubResource("Shape_BottomWall")
|
||||
|
||||
[node name="LeftWall" type="CollisionShape2D" parent="StaticCollision/CafeBoundary" unique_id=1817681571]
|
||||
position = Vector2(-782, -5)
|
||||
shape = SubResource("Shape_LeftWall")
|
||||
|
||||
[node name="RightWall" type="CollisionShape2D" parent="StaticCollision/CafeBoundary" unique_id=19691881]
|
||||
position = Vector2(761, 196)
|
||||
shape = SubResource("Shape_RightWall")
|
||||
|
||||
[node name="BarCounterCollision" type="StaticBody2D" parent="StaticCollision" unique_id=2101575249]
|
||||
|
||||
[node name="FrontCounterBand" type="CollisionShape2D" parent="StaticCollision/BarCounterCollision" unique_id=236568381]
|
||||
position = Vector2(2.5, -123)
|
||||
shape = SubResource("Shape_FrontCounterBand")
|
||||
|
||||
[node name="BackEquipmentBand" type="CollisionShape2D" parent="StaticCollision/BarCounterCollision" unique_id=302153346]
|
||||
position = Vector2(0, -435)
|
||||
shape = SubResource("Shape_BackEquipmentBand")
|
||||
|
||||
[node name="LeftServiceFixture" type="CollisionShape2D" parent="StaticCollision/BarCounterCollision" unique_id=1759728122]
|
||||
position = Vector2(-641, -283.5)
|
||||
shape = SubResource("Shape_LeftServiceFixture")
|
||||
|
||||
[node name="RightServiceFixture" type="CollisionShape2D" parent="StaticCollision/BarCounterCollision" unique_id=1239925157]
|
||||
position = Vector2(636, -294.5)
|
||||
shape = SubResource("Shape_RightServiceFixture")
|
||||
|
||||
[node name="PropCollision" type="StaticBody2D" parent="StaticCollision" unique_id=729484024]
|
||||
|
||||
[node name="CafeReceptionDeskCollision" type="CollisionPolygon2D" parent="StaticCollision/PropCollision" unique_id=1834206100]
|
||||
polygon = PackedVector2Array(-119, 60, -107, 43, 117, 43, 112, 74, 86, 116, -104, 105)
|
||||
|
||||
[node name="LeftSelfServiceKiosk01Collision" type="CollisionPolygon2D" parent="StaticCollision/PropCollision" unique_id=1994436927]
|
||||
polygon = PackedVector2Array(-521, 164, -465, 160, -462, 240, -510, 243, -525, 219)
|
||||
|
||||
[node name="LeftSelfServiceKiosk02Collision" type="CollisionPolygon2D" parent="StaticCollision/PropCollision" unique_id=1428046087]
|
||||
polygon = PackedVector2Array(-450, 153, -404, 166, -397, 242, -433, 240, -449, 224)
|
||||
|
||||
[node name="RightSelfServiceKiosk01Collision" type="CollisionPolygon2D" parent="StaticCollision/PropCollision" unique_id=1839282295]
|
||||
polygon = PackedVector2Array(411, 160, 443, 166, 439, 232, 398, 242, 390, 205, 400, 193)
|
||||
|
||||
[node name="RightSelfServiceKiosk02Collision" type="CollisionPolygon2D" parent="StaticCollision/PropCollision" unique_id=1823238674]
|
||||
polygon = PackedVector2Array(463, 161, 517, 162, 519, 226, 510, 243, 468, 235)
|
||||
|
||||
[node name="LeftCornerPlantSignCollision" type="CollisionPolygon2D" parent="StaticCollision/PropCollision" unique_id=57617661]
|
||||
polygon = PackedVector2Array(-747, 325, -750, 203, -679, 208, -637, 296, -602, 329, -594, 411, -645, 422, -739, 410)
|
||||
|
||||
[node name="RightCornerPlantSignCollision" type="CollisionPolygon2D" parent="StaticCollision/PropCollision" unique_id=1013557406]
|
||||
polygon = PackedVector2Array(629, 291, 653, 247, 740, 193, 747, 325, 739, 410, 658, 421, 619, 392)
|
||||
|
||||
[node name="LeftQueueTopRopeCollision" type="CollisionPolygon2D" parent="StaticCollision/PropCollision" unique_id=443030549]
|
||||
position = Vector2(8, -35)
|
||||
polygon = PackedVector2Array(-382, 4, -282, 5, -282, 14, -382, 13)
|
||||
|
||||
[node name="LeftQueueLeftRopeCollision" type="CollisionPolygon2D" parent="StaticCollision/PropCollision" unique_id=782461653]
|
||||
position = Vector2(-18, -2)
|
||||
polygon = PackedVector2Array(-360, 18, -352, -25, -365, 205, -376, 207)
|
||||
|
||||
[node name="LeftQueueRightRopeCollision" type="CollisionPolygon2D" parent="StaticCollision/PropCollision" unique_id=785942935]
|
||||
position = Vector2(-6, -37)
|
||||
polygon = PackedVector2Array(-286, 8, -275, 8, -290, 217, -293, 248)
|
||||
|
||||
[node name="LeftQueueBottomRopeCollision" type="CollisionPolygon2D" parent="StaticCollision/PropCollision" unique_id=141845763]
|
||||
position = Vector2(-330, -326)
|
||||
polygon = PackedVector2Array(-377, 204, -370, 207, -352, 386, -409, 460, -426, 358, -406, 202)
|
||||
|
||||
[node name="RightQueueTopRopeCollision" type="CollisionPolygon2D" parent="StaticCollision/PropCollision" unique_id=332551755]
|
||||
position = Vector2(2, -35)
|
||||
polygon = PackedVector2Array(282, 5, 381, 5, 381, 14, 282, 14)
|
||||
|
||||
[node name="RightQueueLeftRopeCollision" type="CollisionPolygon2D" parent="StaticCollision/PropCollision" unique_id=1268670145]
|
||||
position = Vector2(-2, -40)
|
||||
polygon = PackedVector2Array(276, 8, 287, 8, 308, 258, 294, 254, 291, 193)
|
||||
|
||||
[node name="RightQueueRightRopeCollision" type="CollisionPolygon2D" parent="StaticCollision/PropCollision" unique_id=350292682]
|
||||
position = Vector2(13, 3)
|
||||
polygon = PackedVector2Array(355, -27, 363, 18, 387, 205, 374, 203)
|
||||
|
||||
[node name="RightQueueBottomRopeCollision" type="CollisionPolygon2D" parent="StaticCollision/PropCollision" unique_id=2067709655]
|
||||
position = Vector2(300, -242)
|
||||
polygon = PackedVector2Array(383, 116, 433, 92, 483, 231, 433, 355, 391, 297, 377, 232, 382, 191)
|
||||
|
||||
[node name="InteractionAreas" type="Node2D" parent="." unique_id=1414044788]
|
||||
|
||||
[node name="ExitToWorkZoneArea" type="Area2D" parent="InteractionAreas" unique_id=1132514494]
|
||||
position = Vector2(0, 455)
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="InteractionAreas/ExitToWorkZoneArea" unique_id=1808842525]
|
||||
shape = SubResource("Shape_ExitArea")
|
||||
|
||||
[node name="CafeRecruitmentLogoArea" type="Area2D" parent="InteractionAreas" unique_id=1787759997]
|
||||
position = Vector2(0, 90)
|
||||
collision_layer = 2
|
||||
collision_mask = 0
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="InteractionAreas/CafeRecruitmentLogoArea" unique_id=521534737]
|
||||
position = Vector2(-5.5, -16.5)
|
||||
shape = SubResource("Shape_CafeRecruitmentLogo")
|
||||
|
||||
[node name="Markers" type="Node2D" parent="." unique_id=388355020]
|
||||
|
||||
[node name="DefaultSpawn" type="Marker2D" parent="Markers" unique_id=1348428752]
|
||||
position = Vector2(0, 392)
|
||||
|
||||
[node name="ServiceIdlePoints" type="Node2D" parent="Markers" unique_id=917409132]
|
||||
|
||||
[node name="ServiceIdlePoint01" type="Marker2D" parent="Markers/ServiceIdlePoints" unique_id=579915137]
|
||||
position = Vector2(-472, -291)
|
||||
|
||||
[node name="ServiceIdlePoint02" type="Marker2D" parent="Markers/ServiceIdlePoints" unique_id=783535284]
|
||||
position = Vector2(-376, -291)
|
||||
|
||||
[node name="ServiceIdlePoint03" type="Marker2D" parent="Markers/ServiceIdlePoints" unique_id=904507716]
|
||||
position = Vector2(-280, -291)
|
||||
|
||||
[node name="ServiceIdlePoint04" type="Marker2D" parent="Markers/ServiceIdlePoints" unique_id=1203682930]
|
||||
position = Vector2(-184, -291)
|
||||
|
||||
[node name="ServiceIdlePoint05" type="Marker2D" parent="Markers/ServiceIdlePoints" unique_id=705534018]
|
||||
position = Vector2(-90, -291)
|
||||
|
||||
[node name="ServiceIdlePoint06" type="Marker2D" parent="Markers/ServiceIdlePoints" unique_id=227830370]
|
||||
position = Vector2(0, -291)
|
||||
|
||||
[node name="ServiceIdlePoint07" type="Marker2D" parent="Markers/ServiceIdlePoints" unique_id=1258667067]
|
||||
position = Vector2(92, -291)
|
||||
|
||||
[node name="ServiceIdlePoint08" type="Marker2D" parent="Markers/ServiceIdlePoints" unique_id=868571064]
|
||||
position = Vector2(185, -291)
|
||||
|
||||
[node name="ServiceIdlePoint09" type="Marker2D" parent="Markers/ServiceIdlePoints" unique_id=510403492]
|
||||
position = Vector2(277, -291)
|
||||
|
||||
[node name="ServiceIdlePoint10" type="Marker2D" parent="Markers/ServiceIdlePoints" unique_id=527786790]
|
||||
position = Vector2(371, -291)
|
||||
|
||||
[node name="ServiceIdlePoint11" type="Marker2D" parent="Markers/ServiceIdlePoints" unique_id=29178558]
|
||||
position = Vector2(-484, -223)
|
||||
|
||||
[node name="ServiceIdlePoint12" type="Marker2D" parent="Markers/ServiceIdlePoints" unique_id=61920657]
|
||||
position = Vector2(-384, -223)
|
||||
|
||||
[node name="ServiceIdlePoint13" type="Marker2D" parent="Markers/ServiceIdlePoints" unique_id=882725816]
|
||||
position = Vector2(-287, -223)
|
||||
|
||||
[node name="ServiceIdlePoint14" type="Marker2D" parent="Markers/ServiceIdlePoints" unique_id=748399169]
|
||||
position = Vector2(-191, -223)
|
||||
|
||||
[node name="ServiceIdlePoint15" type="Marker2D" parent="Markers/ServiceIdlePoints" unique_id=197622030]
|
||||
position = Vector2(-96, -223)
|
||||
|
||||
[node name="ServiceIdlePoint16" type="Marker2D" parent="Markers/ServiceIdlePoints" unique_id=1362126027]
|
||||
position = Vector2(-2, -223)
|
||||
|
||||
[node name="ServiceIdlePoint17" type="Marker2D" parent="Markers/ServiceIdlePoints" unique_id=2008866192]
|
||||
position = Vector2(92, -223)
|
||||
|
||||
[node name="ServiceIdlePoint18" type="Marker2D" parent="Markers/ServiceIdlePoints" unique_id=69828930]
|
||||
position = Vector2(188, -223)
|
||||
|
||||
[node name="ServiceIdlePoint19" type="Marker2D" parent="Markers/ServiceIdlePoints" unique_id=294392180]
|
||||
position = Vector2(281, -223)
|
||||
|
||||
[node name="ServiceIdlePoint20" type="Marker2D" parent="Markers/ServiceIdlePoints" unique_id=1006333228]
|
||||
position = Vector2(378, -223)
|
||||
97
scenes/Maps/personal_space.tscn
Normal file
97
scenes/Maps/personal_space.tscn
Normal file
@@ -0,0 +1,97 @@
|
||||
[gd_scene load_steps=12 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"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/characters/player.tscn" id="3_player"]
|
||||
[ext_resource type="Script" path="res://scenes/Maps/ScenePortal.gd" id="4_portal"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/ui/ChatUI.tscn" id="5_chatui"]
|
||||
[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"]
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="Shape_TopWall"]
|
||||
size = Vector2(882, 76)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="Shape_LeftWall"]
|
||||
size = Vector2(133, 518)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="Shape_RightWall"]
|
||||
size = Vector2(133, 518)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="Shape_ExitPortal"]
|
||||
size = Vector2(105, 57)
|
||||
|
||||
[node name="PersonalSpace" type="Node2D"]
|
||||
script = ExtResource("1_personal_space")
|
||||
|
||||
[node name="UILayer" type="CanvasLayer" parent="."]
|
||||
layer = 10
|
||||
|
||||
[node name="ChatUI" parent="UILayer" instance=ExtResource("5_chatui")]
|
||||
|
||||
[node name="PlayerHud" parent="UILayer" instance=ExtResource("6_playerhud")]
|
||||
|
||||
[node name="FriendListPanel" parent="UILayer" instance=ExtResource("7_friendpanel")]
|
||||
|
||||
[node name="SettingsPanel" parent="UILayer" instance=ExtResource("8_settingspanel")]
|
||||
|
||||
[node name="StageBackdrop" type="ColorRect" parent="."]
|
||||
z_index = -200
|
||||
offset_left = -2000.0
|
||||
offset_top = -1400.0
|
||||
offset_right = 2000.0
|
||||
offset_bottom = 1400.0
|
||||
color = Color(0.118, 0.149, 0.18, 1)
|
||||
|
||||
[node name="RoomBase" type="Sprite2D" parent="."]
|
||||
z_index = -100
|
||||
texture_filter = 2
|
||||
scale = Vector2(0.7, 0.7)
|
||||
texture = ExtResource("2_room_base")
|
||||
|
||||
[node name="YSortWorld" type="Node2D" parent="."]
|
||||
y_sort_enabled = true
|
||||
|
||||
[node name="Characters" type="Node2D" parent="YSortWorld"]
|
||||
y_sort_enabled = true
|
||||
|
||||
[node name="Players" type="Node2D" parent="YSortWorld/Characters"]
|
||||
y_sort_enabled = true
|
||||
|
||||
[node name="Player" parent="YSortWorld/Characters/Players" instance=ExtResource("3_player")]
|
||||
position = Vector2(0, 217)
|
||||
|
||||
[node name="StaticCollision" type="Node2D" parent="."]
|
||||
|
||||
[node name="RoomBoundary" type="StaticBody2D" parent="StaticCollision"]
|
||||
|
||||
[node name="TopWall" type="CollisionShape2D" parent="StaticCollision/RoomBoundary"]
|
||||
position = Vector2(0, -210)
|
||||
shape = SubResource("Shape_TopWall")
|
||||
|
||||
[node name="LeftWall" type="CollisionShape2D" parent="StaticCollision/RoomBoundary"]
|
||||
position = Vector2(-483, 14)
|
||||
shape = SubResource("Shape_LeftWall")
|
||||
|
||||
[node name="RightWall" type="CollisionShape2D" parent="StaticCollision/RoomBoundary"]
|
||||
position = Vector2(483, 14)
|
||||
shape = SubResource("Shape_RightWall")
|
||||
|
||||
[node name="InteractionAreas" type="Node2D" parent="."]
|
||||
|
||||
[node name="ExitToSquareArea" type="Area2D" parent="InteractionAreas"]
|
||||
position = Vector2(0, 273)
|
||||
script = ExtResource("4_portal")
|
||||
targetSceneName = "square"
|
||||
targetSpawnName = "NeighborhoodTeleport"
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="InteractionAreas/ExitToSquareArea"]
|
||||
shape = SubResource("Shape_ExitPortal")
|
||||
|
||||
[node name="Markers" type="Node2D" parent="."]
|
||||
|
||||
[node name="DefaultSpawn" type="Marker2D" parent="Markers"]
|
||||
position = Vector2(0, 200)
|
||||
|
||||
[node name="FromNeighborhood" type="Marker2D" parent="Markers"]
|
||||
position = Vector2(0, 214)
|
||||
1459
scenes/Maps/square.tscn
Normal file
1459
scenes/Maps/square.tscn
Normal file
File diff suppressed because one or more lines are too long
424
scenes/Maps/work_zone.tscn
Normal file
424
scenes/Maps/work_zone.tscn
Normal file
File diff suppressed because one or more lines are too long
103
scenes/characters/CafeCompanionTarget.gd
Normal file
103
scenes/characters/CafeCompanionTarget.gd
Normal file
@@ -0,0 +1,103 @@
|
||||
extends Area2D
|
||||
class_name CafeCompanionTarget
|
||||
|
||||
# ============================================================================
|
||||
# CafeCompanionTarget.gd - 咖啡店陪伴机器人点击组件
|
||||
# ============================================================================
|
||||
# 挂在咖啡店 NPC 或被雇佣玩家节点下,负责把鼠标点击转成
|
||||
# “选择陪伴机器人”事件;真正聊天必须由付费时长购买后开启。
|
||||
# ============================================================================
|
||||
|
||||
@export var servicePointId: String = ""
|
||||
@export var companionId: String = ""
|
||||
@export var companionType: String = "npc"
|
||||
@export var personaName: String = ""
|
||||
@export var ownerUserId: String = ""
|
||||
@export var employmentEndsAt: String = ""
|
||||
@export var selfTarget: bool = false
|
||||
|
||||
var _lastClickMsec: int = 0
|
||||
|
||||
func _ready() -> void:
|
||||
input_pickable = true
|
||||
set_process_unhandled_input(true)
|
||||
|
||||
func _input_event(_viewport: Viewport, event: InputEvent, _shapeIdx: int) -> void:
|
||||
if not (event is InputEventMouseButton):
|
||||
return
|
||||
var mouseEvent := event as InputEventMouseButton
|
||||
if mouseEvent.button_index != MOUSE_BUTTON_LEFT or not mouseEvent.pressed:
|
||||
return
|
||||
|
||||
get_viewport().set_input_as_handled()
|
||||
_try_emit_target_selected()
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not (event is InputEventMouseButton):
|
||||
return
|
||||
var mouseEvent := event as InputEventMouseButton
|
||||
if mouseEvent.button_index != MOUSE_BUTTON_LEFT or not mouseEvent.pressed:
|
||||
return
|
||||
if not _contains_global_point(get_global_mouse_position()):
|
||||
return
|
||||
|
||||
get_viewport().set_input_as_handled()
|
||||
_try_emit_target_selected()
|
||||
|
||||
func _try_emit_target_selected() -> void:
|
||||
var now := Time.get_ticks_msec()
|
||||
if _lastClickMsec > 0 and now - _lastClickMsec < 120:
|
||||
return
|
||||
_lastClickMsec = now
|
||||
_emit_target_selected()
|
||||
|
||||
func _emit_target_selected() -> void:
|
||||
var eventSystem := get_node_or_null("/root/EventSystem")
|
||||
if eventSystem == null:
|
||||
return
|
||||
|
||||
var payload := {
|
||||
"cafe_id": "whale_cafe",
|
||||
"service_point_id": servicePointId,
|
||||
"companion_id": companionId,
|
||||
"companion_type": companionType,
|
||||
"persona_name": _resolved_persona_name(),
|
||||
"owner_user_id": ownerUserId,
|
||||
"employment_ends_at": employmentEndsAt,
|
||||
"target_path": str(get_parent().get_path()) if get_parent() != null else "",
|
||||
}
|
||||
eventSystem.call("emit_event", EventNames.CAFE_COMPANION_SELF_SELECTED if selfTarget else EventNames.CAFE_COMPANION_SELECTED, payload)
|
||||
|
||||
func _resolved_persona_name() -> String:
|
||||
var normalizedName := personaName.strip_edges()
|
||||
if not normalizedName.is_empty():
|
||||
return normalizedName
|
||||
var parent := get_parent()
|
||||
if parent != null and parent.get("npcName") != null:
|
||||
return str(parent.get("npcName")).strip_edges()
|
||||
return companionId
|
||||
|
||||
func _contains_global_point(globalPoint: Vector2) -> bool:
|
||||
for child in get_children():
|
||||
var shapeNode := child as CollisionShape2D
|
||||
if shapeNode == null or shapeNode.disabled or shapeNode.shape == null:
|
||||
continue
|
||||
if _shape_contains_point(shapeNode, globalPoint):
|
||||
return true
|
||||
return false
|
||||
|
||||
func _shape_contains_point(shapeNode: CollisionShape2D, globalPoint: Vector2) -> bool:
|
||||
var localPoint := shapeNode.to_local(globalPoint)
|
||||
var shape := shapeNode.shape
|
||||
if shape is RectangleShape2D:
|
||||
var rectangle := shape as RectangleShape2D
|
||||
return Rect2(rectangle.size * -0.5, rectangle.size).has_point(localPoint)
|
||||
if shape is CircleShape2D:
|
||||
var circle := shape as CircleShape2D
|
||||
return localPoint.length() <= circle.radius
|
||||
if shape is CapsuleShape2D:
|
||||
var capsule := shape as CapsuleShape2D
|
||||
var halfHeight: float = maxf(0.0, capsule.height * 0.5 - capsule.radius)
|
||||
var clampedY: float = clampf(localPoint.y, -halfHeight, halfHeight)
|
||||
return Vector2(localPoint.x, localPoint.y - clampedY).length() <= capsule.radius
|
||||
return false
|
||||
1
scenes/characters/CafeCompanionTarget.gd.uid
Normal file
1
scenes/characters/CafeCompanionTarget.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d1sfqui0pqalf
|
||||
156
scenes/characters/NPCController.gd
Normal file
156
scenes/characters/NPCController.gd
Normal file
@@ -0,0 +1,156 @@
|
||||
extends CharacterBody2D
|
||||
class_name NPCController
|
||||
|
||||
# ============================================================================
|
||||
# 文件名: NPCController.gd
|
||||
# 作用: 通用 NPC 控制器,负责角色待机表现与交互对话
|
||||
#
|
||||
# 主要功能:
|
||||
# - 播放 NPC 待机动画
|
||||
# - 响应玩家射线交互
|
||||
# - 触发聊天气泡与 NPC 对话事件
|
||||
#
|
||||
# 依赖: EventSystem, EventNames, ChatBubble
|
||||
# 作者: Codex
|
||||
# 创建时间: 2026-03-10
|
||||
# ============================================================================
|
||||
|
||||
signal interaction_happened(text: String)
|
||||
|
||||
const CHAT_BUBBLE_SCENE: PackedScene = preload("res://scenes/ui/ChatBubble.tscn")
|
||||
const CHAT_BUBBLE_LAYER_NAME: String = "WorldChatBubbleLayer"
|
||||
const CHAT_BUBBLE_TARGET_OFFSET: Vector2 = Vector2(0, -84)
|
||||
const NPC_TALKED_EVENT: String = "npc_talked"
|
||||
const WORLD_SORT_Z_OFFSET: int = 2048
|
||||
const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres")
|
||||
const NAMEPLATE_RENDER_SCALE: float = 0.5
|
||||
const NAMEPLATE_FONT_SIZE: int = 24
|
||||
const NAMEPLATE_VISUAL_HEIGHT: int = 22
|
||||
const NAMEPLATE_VISUAL_MIN_WIDTH: int = 76
|
||||
const NAMEPLATE_VISUAL_MAX_WIDTH: int = 118
|
||||
const NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12
|
||||
|
||||
@export var npcName: String = "NPC"
|
||||
@export_multiline var dialogue: String = "欢迎来到WhaleTown,我是镇长范鲸晶"
|
||||
@export var showNameplate: bool = false
|
||||
@export var nameplateOffsetY: float = -112.0
|
||||
|
||||
@onready var animation_player: AnimationPlayer = $AnimationPlayer
|
||||
|
||||
var _nameplate: Label
|
||||
|
||||
func _ready() -> void:
|
||||
# 播放场景里配置好的待机动画,让不同 NPC 可以复用同一个控制器。
|
||||
if animation_player.has_animation("idle"):
|
||||
animation_player.play("idle")
|
||||
_update_nameplate()
|
||||
_update_world_sort_z()
|
||||
|
||||
# 保持 NPC 可被玩家射线与角色碰撞识别。
|
||||
collision_layer = 3
|
||||
collision_mask = 3
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
_update_world_sort_z()
|
||||
|
||||
# 处理玩家交互,展示气泡并向全局事件系统广播。
|
||||
func interact() -> void:
|
||||
show_bubble(dialogue)
|
||||
var eventSystem: Node = get_node_or_null("/root/EventSystem")
|
||||
if eventSystem != null:
|
||||
eventSystem.call("emit_event", NPC_TALKED_EVENT, {
|
||||
"npc": self,
|
||||
"npc_name": npcName,
|
||||
"dialogue": dialogue
|
||||
})
|
||||
interaction_happened.emit(dialogue)
|
||||
|
||||
# 在 NPC 头顶生成一次性聊天气泡。
|
||||
#
|
||||
# 参数:
|
||||
# text: String - 要展示的对话内容
|
||||
func show_bubble(text: String) -> void:
|
||||
var bubble: Control = CHAT_BUBBLE_SCENE.instantiate() as Control
|
||||
if bubble == null:
|
||||
return
|
||||
var bubbleLayer: CanvasLayer = _get_chat_bubble_layer()
|
||||
bubbleLayer.add_child(bubble)
|
||||
if bubble.has_method("set_text"):
|
||||
bubble.call("set_text", text, self, CHAT_BUBBLE_TARGET_OFFSET)
|
||||
|
||||
func _get_chat_bubble_layer() -> CanvasLayer:
|
||||
var root: Window = get_tree().root
|
||||
var layer: CanvasLayer = root.get_node_or_null(CHAT_BUBBLE_LAYER_NAME) as CanvasLayer
|
||||
if layer != null:
|
||||
return layer
|
||||
|
||||
layer = CanvasLayer.new()
|
||||
layer.name = CHAT_BUBBLE_LAYER_NAME
|
||||
layer.layer = 20
|
||||
root.add_child(layer)
|
||||
return layer
|
||||
|
||||
func _update_world_sort_z() -> void:
|
||||
z_index = WORLD_SORT_Z_OFFSET + int(round(global_position.y))
|
||||
|
||||
func _update_nameplate() -> void:
|
||||
if not showNameplate:
|
||||
if is_instance_valid(_nameplate):
|
||||
_nameplate.visible = false
|
||||
return
|
||||
if not is_instance_valid(_nameplate):
|
||||
_nameplate = Label.new()
|
||||
_nameplate.name = "Nameplate"
|
||||
add_child(_nameplate)
|
||||
|
||||
var displayName := npcName.strip_edges()
|
||||
if displayName.is_empty():
|
||||
displayName = "NPC"
|
||||
var visualWidth := _nameplate_visual_width(displayName)
|
||||
var renderWidth := ceili(float(visualWidth) / NAMEPLATE_RENDER_SCALE)
|
||||
var renderHeight := ceili(float(NAMEPLATE_VISUAL_HEIGHT) / NAMEPLATE_RENDER_SCALE)
|
||||
|
||||
_nameplate.theme = WORLD_TEXT_THEME
|
||||
_nameplate.text = displayName
|
||||
_nameplate.visible = true
|
||||
_nameplate.z_index = 30
|
||||
_nameplate.scale = Vector2.ONE * NAMEPLATE_RENDER_SCALE
|
||||
_nameplate.position = Vector2(float(visualWidth) * -0.5, nameplateOffsetY)
|
||||
_nameplate.custom_minimum_size = Vector2(renderWidth, renderHeight)
|
||||
_nameplate.size = _nameplate.custom_minimum_size
|
||||
_nameplate.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_nameplate.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_nameplate.clip_text = true
|
||||
_nameplate.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
_nameplate.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_nameplate.add_theme_color_override("font_color", Color(0.12, 0.20, 0.24, 1.0))
|
||||
_nameplate.add_theme_color_override("font_shadow_color", Color(1.0, 1.0, 1.0, 0.85))
|
||||
_nameplate.add_theme_constant_override("shadow_offset_x", 0)
|
||||
_nameplate.add_theme_constant_override("shadow_offset_y", 1)
|
||||
_nameplate.add_theme_font_size_override("font_size", NAMEPLATE_FONT_SIZE)
|
||||
_nameplate.add_theme_stylebox_override("normal", _create_nameplate_style())
|
||||
|
||||
func _nameplate_visual_width(displayName: String) -> int:
|
||||
var estimatedWidth := displayName.length() * NAMEPLATE_VISUAL_CHAR_WIDTH + 28
|
||||
return clampi(estimatedWidth, NAMEPLATE_VISUAL_MIN_WIDTH, NAMEPLATE_VISUAL_MAX_WIDTH)
|
||||
|
||||
func _create_nameplate_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(1.0, 0.988, 0.955, 0.96)
|
||||
style.border_color = Color(0.18, 0.34, 0.38, 0.92)
|
||||
style.border_width_left = 2
|
||||
style.border_width_top = 2
|
||||
style.border_width_right = 2
|
||||
style.border_width_bottom = 2
|
||||
style.corner_radius_top_left = 12
|
||||
style.corner_radius_top_right = 12
|
||||
style.corner_radius_bottom_left = 12
|
||||
style.corner_radius_bottom_right = 12
|
||||
style.content_margin_left = 12
|
||||
style.content_margin_top = 4
|
||||
style.content_margin_right = 12
|
||||
style.content_margin_bottom = 4
|
||||
style.shadow_color = Color(0.05, 0.08, 0.09, 0.18)
|
||||
style.shadow_size = 4
|
||||
style.shadow_offset = Vector2(0, 2)
|
||||
return style
|
||||
1
scenes/characters/NPCController.gd.uid
Normal file
1
scenes/characters/NPCController.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dqgblo33v4mfv
|
||||
283
scenes/characters/PlayerController.gd
Normal file
283
scenes/characters/PlayerController.gd
Normal file
@@ -0,0 +1,283 @@
|
||||
extends CharacterBody2D
|
||||
class_name PlayerController
|
||||
|
||||
# 信号定义
|
||||
signal player_moved(position: Vector2)
|
||||
|
||||
# 常量定义
|
||||
const MOVE_SPEED = 200.0
|
||||
const WORLD_SORT_Z_OFFSET = 2048
|
||||
const INTERACTION_COLLISION_MASK = 2
|
||||
const WALK_ANIMATION_LENGTH = 0.8
|
||||
const NAME_LABEL_OFFSET: Vector2 = Vector2(-70, -112)
|
||||
const DIRECTION_ROWS: Dictionary = {
|
||||
"down": 0,
|
||||
"up": 1,
|
||||
"right": 2,
|
||||
"left": 3,
|
||||
}
|
||||
|
||||
# 节点引用
|
||||
@onready var animation_player: AnimationPlayer = $AnimationPlayer
|
||||
@onready var sprite: Sprite2D = $Sprite2D
|
||||
@onready var ray_cast: RayCast2D = $RayCast2D
|
||||
|
||||
var lastDirection: String = "down"
|
||||
var _nameLabel: Label
|
||||
var _movementLocked: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
_reset_movement_input_state()
|
||||
_apply_current_appearance()
|
||||
_subscribe_to_appearance_events()
|
||||
_create_name_label()
|
||||
_update_name_label()
|
||||
|
||||
# 检查是否有初始位置设置
|
||||
call_deferred("_check_spawn_position")
|
||||
|
||||
# 播放初始动画
|
||||
if animation_player.has_animation("idle_down"):
|
||||
animation_player.play("idle_down")
|
||||
|
||||
# Initialize RayCast
|
||||
ray_cast.add_exception(self) # Ignore local player
|
||||
ray_cast.enabled = true
|
||||
ray_cast.collide_with_areas = true
|
||||
ray_cast.collide_with_bodies = true
|
||||
ray_cast.collision_mask = INTERACTION_COLLISION_MASK
|
||||
ray_cast.target_position = Vector2(0, 60)
|
||||
_update_world_sort_z()
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var eventSystem := get_node_or_null("/root/EventSystem")
|
||||
if eventSystem != null:
|
||||
eventSystem.call("disconnect_event", EventNames.APPEARANCE_SKIN_CHANGED, _on_appearance_skin_changed, self)
|
||||
eventSystem.call("disconnect_event", EventNames.SETTINGS_CHANGED, _on_settings_changed, self)
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_APPLICATION_FOCUS_IN:
|
||||
_reset_movement_input_state()
|
||||
|
||||
func _reset_movement_input_state() -> void:
|
||||
Input.flush_buffered_events()
|
||||
_release_movement_actions()
|
||||
|
||||
func _release_movement_actions() -> void:
|
||||
Input.action_release("move_left")
|
||||
Input.action_release("move_right")
|
||||
Input.action_release("move_up")
|
||||
Input.action_release("move_down")
|
||||
|
||||
func _check_spawn_position() -> void:
|
||||
var spawnPos: Variant = SceneManager.get_next_scene_position()
|
||||
if spawnPos != null:
|
||||
global_position = spawnPos
|
||||
_update_world_sort_z()
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_handle_movement(delta)
|
||||
_update_world_sort_z()
|
||||
_handle_interaction()
|
||||
|
||||
func _handle_interaction() -> void:
|
||||
if _is_text_input_focused():
|
||||
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 _handle_movement(_delta: float) -> void:
|
||||
if _movementLocked:
|
||||
_release_movement_actions()
|
||||
velocity = Vector2.ZERO
|
||||
_play_idle_animation()
|
||||
move_and_slide()
|
||||
return
|
||||
|
||||
# 输入框获得焦点时禁止移动,避免聊天/表单输入影响角色
|
||||
if _is_text_input_focused():
|
||||
_release_movement_actions()
|
||||
velocity = Vector2.ZERO
|
||||
_play_idle_animation()
|
||||
move_and_slide()
|
||||
return
|
||||
|
||||
# 获取移动向量 (参考 docs/02-开发规范/输入映射配置.md)
|
||||
var direction := Input.get_vector(
|
||||
"move_left", "move_right",
|
||||
"move_up", "move_down"
|
||||
)
|
||||
|
||||
# 应用移动
|
||||
if direction != Vector2.ZERO:
|
||||
velocity = direction * MOVE_SPEED
|
||||
_update_animation_state(direction)
|
||||
else:
|
||||
velocity = Vector2.ZERO
|
||||
_play_idle_animation()
|
||||
|
||||
move_and_slide()
|
||||
|
||||
# 发送移动事件 (如果位置发生明显变化)
|
||||
if velocity.length() > 0:
|
||||
player_moved.emit(global_position)
|
||||
EventSystem.emit_event(EventNames.PLAYER_MOVED, {
|
||||
"position": global_position
|
||||
})
|
||||
|
||||
func _update_animation_state(direction: Vector2) -> void:
|
||||
if not animation_player:
|
||||
return
|
||||
|
||||
# Determine primary direction
|
||||
if abs(direction.x) > abs(direction.y):
|
||||
if direction.x > 0:
|
||||
lastDirection = "right"
|
||||
ray_cast.target_position = Vector2(60, 0)
|
||||
else:
|
||||
lastDirection = "left"
|
||||
ray_cast.target_position = Vector2(-60, 0)
|
||||
else:
|
||||
if direction.y > 0:
|
||||
lastDirection = "down"
|
||||
ray_cast.target_position = Vector2(0, 60)
|
||||
else:
|
||||
lastDirection = "up"
|
||||
ray_cast.target_position = Vector2(0, -60)
|
||||
|
||||
animation_player.play("walk_" + lastDirection)
|
||||
|
||||
func _play_idle_animation() -> void:
|
||||
if animation_player:
|
||||
animation_player.play("idle_" + lastDirection)
|
||||
|
||||
func set_movement_locked(locked: bool) -> void:
|
||||
_movementLocked = locked
|
||||
if locked:
|
||||
_release_movement_actions()
|
||||
velocity = Vector2.ZERO
|
||||
_play_idle_animation()
|
||||
|
||||
func _update_world_sort_z() -> void:
|
||||
z_index = WORLD_SORT_Z_OFFSET + int(round(global_position.y))
|
||||
|
||||
func _subscribe_to_appearance_events() -> void:
|
||||
var eventSystem := get_node_or_null("/root/EventSystem")
|
||||
if eventSystem != null:
|
||||
eventSystem.call("connect_event", EventNames.APPEARANCE_SKIN_CHANGED, _on_appearance_skin_changed, self)
|
||||
eventSystem.call("connect_event", EventNames.SETTINGS_CHANGED, _on_settings_changed, self)
|
||||
|
||||
func _on_appearance_skin_changed(_data: Dictionary) -> void:
|
||||
_apply_current_appearance()
|
||||
|
||||
func _on_settings_changed(_data: Dictionary) -> void:
|
||||
_update_name_label()
|
||||
|
||||
func _apply_current_appearance() -> void:
|
||||
var appearanceManager := get_node_or_null("/root/AppearanceManager")
|
||||
if appearanceManager != null and appearanceManager.has_method("apply_skin_to_sprite"):
|
||||
appearanceManager.call("apply_skin_to_sprite", sprite)
|
||||
_configure_directional_animations()
|
||||
|
||||
func _configure_directional_animations() -> void:
|
||||
if animation_player == null or sprite == null:
|
||||
return
|
||||
|
||||
var library := animation_player.get_animation_library("")
|
||||
if library == null:
|
||||
library = AnimationLibrary.new()
|
||||
animation_player.add_animation_library("", library)
|
||||
|
||||
var frameCount: int = max(1, sprite.hframes)
|
||||
for direction in DIRECTION_ROWS.keys():
|
||||
var row := int(DIRECTION_ROWS[direction])
|
||||
_set_frame_animation(library, "idle_%s" % direction, [row * frameCount], 0.1)
|
||||
var walkFrames: Array[int] = []
|
||||
for column in range(frameCount):
|
||||
walkFrames.append(row * frameCount + column)
|
||||
_set_frame_animation(library, "walk_%s" % direction, walkFrames, WALK_ANIMATION_LENGTH)
|
||||
|
||||
func _set_frame_animation(library: AnimationLibrary, animationName: String, frameValues: Array[int], length: float) -> void:
|
||||
if library.has_animation(animationName):
|
||||
library.remove_animation(animationName)
|
||||
|
||||
var animation := Animation.new()
|
||||
animation.resource_name = animationName
|
||||
animation.length = length
|
||||
animation.loop_mode = Animation.LOOP_LINEAR
|
||||
|
||||
var trackIndex := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(trackIndex, NodePath("Sprite2D:frame"))
|
||||
animation.value_track_set_update_mode(trackIndex, Animation.UPDATE_DISCRETE)
|
||||
|
||||
var frameStep: float = length / float(max(1, frameValues.size()))
|
||||
for index in range(frameValues.size()):
|
||||
animation.track_insert_key(trackIndex, index * frameStep, frameValues[index])
|
||||
|
||||
library.add_animation(animationName, animation)
|
||||
|
||||
func _is_text_input_focused() -> bool:
|
||||
var focusOwner: Control = get_viewport().gui_get_focus_owner()
|
||||
if focusOwner == null or not focusOwner.is_inside_tree() or not focusOwner.is_visible_in_tree():
|
||||
return false
|
||||
if focusOwner is LineEdit:
|
||||
var lineEdit := focusOwner as LineEdit
|
||||
return lineEdit.editable
|
||||
if focusOwner is TextEdit:
|
||||
var textEdit := focusOwner as TextEdit
|
||||
return textEdit.editable
|
||||
return false
|
||||
|
||||
func _create_name_label() -> void:
|
||||
if is_instance_valid(_nameLabel):
|
||||
return
|
||||
_nameLabel = Label.new()
|
||||
_nameLabel.name = "NameLabel"
|
||||
_nameLabel.position = NAME_LABEL_OFFSET
|
||||
_nameLabel.custom_minimum_size = Vector2(140, 28)
|
||||
_nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_nameLabel.add_theme_color_override("font_color", Color(0.188, 0.294, 0.424))
|
||||
_nameLabel.add_theme_font_size_override("font_size", 14)
|
||||
_nameLabel.add_theme_stylebox_override("normal", _create_name_label_style())
|
||||
add_child(_nameLabel)
|
||||
|
||||
func _update_name_label() -> void:
|
||||
if not is_instance_valid(_nameLabel):
|
||||
return
|
||||
_nameLabel.text = _current_username()
|
||||
_nameLabel.visible = _settings_bool("show_name_always", false)
|
||||
|
||||
func _current_username() -> String:
|
||||
var authManager := get_node_or_null("/root/AuthManager")
|
||||
if authManager != null and authManager.has_method("get_current_username"):
|
||||
var username := str(authManager.call("get_current_username")).strip_edges()
|
||||
if not username.is_empty():
|
||||
return username
|
||||
return "玩家"
|
||||
|
||||
func _settings_bool(key: String, defaultValue: bool) -> bool:
|
||||
var settingsManager := get_node_or_null("/root/SettingsManager")
|
||||
if settingsManager != null and settingsManager.has_method("get_bool"):
|
||||
return bool(settingsManager.call("get_bool", key))
|
||||
return defaultValue
|
||||
|
||||
func _create_name_label_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(1.0, 1.0, 1.0, 0.74)
|
||||
style.corner_radius_top_left = 10
|
||||
style.corner_radius_top_right = 10
|
||||
style.corner_radius_bottom_left = 10
|
||||
style.corner_radius_bottom_right = 10
|
||||
style.content_margin_left = 8
|
||||
style.content_margin_right = 8
|
||||
style.content_margin_top = 4
|
||||
style.content_margin_bottom = 4
|
||||
return style
|
||||
1
scenes/characters/PlayerController.gd.uid
Normal file
1
scenes/characters/PlayerController.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://lwki8qk6adle
|
||||
375
scenes/characters/RemotePlayer.gd
Normal file
375
scenes/characters/RemotePlayer.gd
Normal file
@@ -0,0 +1,375 @@
|
||||
extends CharacterBody2D
|
||||
class_name RemotePlayer
|
||||
|
||||
# 远程玩家脚本
|
||||
# 负责处理位置同步和动画播放
|
||||
# 严格遵循 Visual Only 原则:无输入处理,无物理碰撞
|
||||
|
||||
var userId: String = ""
|
||||
var username: String = ""
|
||||
var skinId: String = ""
|
||||
var skinAsset: Dictionary = {}
|
||||
var avatarId: String = ""
|
||||
var targetPosition: Vector2 = Vector2.ZERO
|
||||
var cafeCompanionData: Dictionary = {}
|
||||
|
||||
# 内部状态
|
||||
var lastDirection: String = "down"
|
||||
const WORLD_SORT_Z_OFFSET = 2048
|
||||
const WALK_ANIMATION_LENGTH = 0.8
|
||||
const NAME_LABEL_OFFSET: Vector2 = Vector2(-70, -112)
|
||||
const CAFE_NAME_LABEL_RENDER_SCALE: float = 0.5
|
||||
const CAFE_NAME_LABEL_FONT_SIZE: int = 24
|
||||
const CAFE_NAME_LABEL_VISUAL_HEIGHT: int = 22
|
||||
const CAFE_NAME_LABEL_VISUAL_MIN_WIDTH: int = 76
|
||||
const CAFE_NAME_LABEL_VISUAL_MAX_WIDTH: int = 118
|
||||
const CAFE_NAME_LABEL_VISUAL_CHAR_WIDTH: int = 12
|
||||
const CAFE_NAME_LABEL_OFFSET_Y: float = -96.0
|
||||
const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres")
|
||||
const DIRECTION_ROWS: Dictionary = {
|
||||
"down": 0,
|
||||
"up": 1,
|
||||
"right": 2,
|
||||
"left": 3,
|
||||
}
|
||||
@onready var animation_player: AnimationPlayer = $AnimationPlayer
|
||||
@onready var sprite: Sprite2D = $Sprite2D
|
||||
var _nameLabel: Label
|
||||
var _cafeCompanionTarget: CafeCompanionTarget
|
||||
|
||||
func _ready() -> void:
|
||||
# 初始化时确保无物理处理
|
||||
set_physics_process(false)
|
||||
# 初始位置设为当前位置
|
||||
targetPosition = global_position
|
||||
_apply_appearance()
|
||||
_update_world_sort_z()
|
||||
_create_name_label()
|
||||
_update_name_label()
|
||||
_subscribe_to_settings_events()
|
||||
|
||||
# 确保禁用物理碰撞 (双重保险)
|
||||
if has_node("CollisionShape2D"):
|
||||
$CollisionShape2D.disabled = true
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var eventSystem := get_node_or_null("/root/EventSystem")
|
||||
if eventSystem != null:
|
||||
eventSystem.call("disconnect_event", EventNames.SETTINGS_CHANGED, _on_settings_changed, self)
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# 1. 平滑移动插值
|
||||
var currentPos: Vector2 = global_position
|
||||
var distance: float = currentPos.distance_to(targetPosition)
|
||||
|
||||
if distance > 1.0:
|
||||
# 简单的线性插值,速度系数 10.0 可根据需要调整
|
||||
var newPos: Vector2 = currentPos.lerp(targetPosition, 10.0 * delta)
|
||||
|
||||
# 计算移动向量用于动画朝向
|
||||
var moveVec: Vector2 = newPos - currentPos
|
||||
_update_animation(moveVec)
|
||||
|
||||
global_position = newPos
|
||||
_update_world_sort_z()
|
||||
else:
|
||||
# 距离很近时直接吸附并播放待机动画
|
||||
global_position = targetPosition
|
||||
_update_world_sort_z()
|
||||
_play_idle_animation()
|
||||
|
||||
# 统一初始化方法
|
||||
# data: 包含 camelCase 字段的字典 (userId, username, position 等)
|
||||
func setup(data: Dictionary) -> void:
|
||||
if data.has("userId"):
|
||||
userId = data.userId
|
||||
if data.has("username"):
|
||||
username = str(data.username)
|
||||
if data.has("skin_id"):
|
||||
skinId = str(data.get("skin_id", ""))
|
||||
elif data.has("skinId"):
|
||||
skinId = str(data.get("skinId", ""))
|
||||
if data.has("skin_asset"):
|
||||
var skinAssetData: Variant = data.get("skin_asset", {})
|
||||
skinAsset = skinAssetData if skinAssetData is Dictionary else {}
|
||||
elif data.has("skinAsset"):
|
||||
var skinAssetPayload: Variant = data.get("skinAsset", {})
|
||||
skinAsset = skinAssetPayload if skinAssetPayload is Dictionary else {}
|
||||
if data.has("avatar_id"):
|
||||
avatarId = str(data.get("avatar_id", ""))
|
||||
elif data.has("avatarId"):
|
||||
avatarId = str(data.get("avatarId", ""))
|
||||
if data.has("cafe_companion") or data.has("cafeCompanion"):
|
||||
cafeCompanionData = _normalize_cafe_companion_data(data.get("cafe_companion", data.get("cafeCompanion", null)))
|
||||
_apply_appearance()
|
||||
_configure_cafe_companion_target()
|
||||
_update_name_label()
|
||||
|
||||
if data.has("position"):
|
||||
var positionData: Variant = data.position
|
||||
if positionData is Vector2:
|
||||
global_position = positionData
|
||||
targetPosition = positionData
|
||||
_update_world_sort_z()
|
||||
elif positionData.has("x") and positionData.has("y"):
|
||||
var newPos := Vector2(positionData.x, positionData.y)
|
||||
global_position = newPos
|
||||
targetPosition = newPos
|
||||
_update_world_sort_z()
|
||||
|
||||
# 更新目标位置
|
||||
func update_position(newPos: Vector2) -> void:
|
||||
targetPosition = newPos
|
||||
|
||||
func _update_world_sort_z() -> void:
|
||||
z_index = WORLD_SORT_Z_OFFSET + int(round(global_position.y))
|
||||
|
||||
func _update_animation(moveVec: Vector2) -> void:
|
||||
if not animation_player:
|
||||
return
|
||||
|
||||
# 确定主方向
|
||||
if abs(moveVec.x) > abs(moveVec.y):
|
||||
if moveVec.x > 0:
|
||||
lastDirection = "right"
|
||||
else:
|
||||
lastDirection = "left"
|
||||
else:
|
||||
if moveVec.y > 0:
|
||||
lastDirection = "down"
|
||||
else:
|
||||
lastDirection = "up"
|
||||
|
||||
animation_player.play("walk_" + lastDirection)
|
||||
|
||||
func _play_idle_animation() -> void:
|
||||
if animation_player:
|
||||
animation_player.play("idle_" + lastDirection)
|
||||
|
||||
func _apply_appearance() -> void:
|
||||
var appearanceManager := get_node_or_null("/root/AppearanceManager")
|
||||
if appearanceManager == null:
|
||||
return
|
||||
if not skinAsset.is_empty() and appearanceManager.has_method("apply_skin_asset_to_sprite"):
|
||||
appearanceManager.call("apply_skin_asset_to_sprite", sprite, skinId, skinAsset)
|
||||
elif appearanceManager.has_method("apply_skin_to_sprite"):
|
||||
appearanceManager.call("apply_skin_to_sprite", sprite, skinId)
|
||||
_configure_directional_animations()
|
||||
|
||||
func _configure_directional_animations() -> void:
|
||||
if animation_player == null or sprite == null:
|
||||
return
|
||||
|
||||
var library := animation_player.get_animation_library("")
|
||||
if library == null:
|
||||
library = AnimationLibrary.new()
|
||||
animation_player.add_animation_library("", library)
|
||||
|
||||
var frameCount: int = max(1, sprite.hframes)
|
||||
for direction in DIRECTION_ROWS.keys():
|
||||
var row := int(DIRECTION_ROWS[direction])
|
||||
_set_frame_animation(library, "idle_%s" % direction, [row * frameCount], 0.1)
|
||||
var walkFrames: Array[int] = []
|
||||
for column in range(frameCount):
|
||||
walkFrames.append(row * frameCount + column)
|
||||
_set_frame_animation(library, "walk_%s" % direction, walkFrames, WALK_ANIMATION_LENGTH)
|
||||
|
||||
func _set_frame_animation(library: AnimationLibrary, animationName: String, frameValues: Array[int], length: float) -> void:
|
||||
if library.has_animation(animationName):
|
||||
library.remove_animation(animationName)
|
||||
|
||||
var animation := Animation.new()
|
||||
animation.resource_name = animationName
|
||||
animation.length = length
|
||||
animation.loop_mode = Animation.LOOP_LINEAR
|
||||
|
||||
var trackIndex := animation.add_track(Animation.TYPE_VALUE)
|
||||
animation.track_set_path(trackIndex, NodePath("Sprite2D:frame"))
|
||||
animation.value_track_set_update_mode(trackIndex, Animation.UPDATE_DISCRETE)
|
||||
|
||||
var frameStep: float = length / float(max(1, frameValues.size()))
|
||||
for index in range(frameValues.size()):
|
||||
animation.track_insert_key(trackIndex, index * frameStep, frameValues[index])
|
||||
|
||||
library.add_animation(animationName, animation)
|
||||
|
||||
func _subscribe_to_settings_events() -> void:
|
||||
var eventSystem := get_node_or_null("/root/EventSystem")
|
||||
if eventSystem != null:
|
||||
eventSystem.call("connect_event", EventNames.SETTINGS_CHANGED, _on_settings_changed, self)
|
||||
|
||||
func _on_settings_changed(_data: Dictionary) -> void:
|
||||
_update_name_label()
|
||||
|
||||
func _create_name_label() -> void:
|
||||
if is_instance_valid(_nameLabel):
|
||||
return
|
||||
_nameLabel = Label.new()
|
||||
_nameLabel.name = "NameLabel"
|
||||
_nameLabel.position = NAME_LABEL_OFFSET
|
||||
_nameLabel.custom_minimum_size = Vector2(140, 28)
|
||||
_nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_nameLabel.add_theme_color_override("font_color", Color(0.188, 0.294, 0.424))
|
||||
_nameLabel.add_theme_font_size_override("font_size", 14)
|
||||
_nameLabel.add_theme_stylebox_override("normal", _create_name_label_style())
|
||||
add_child(_nameLabel)
|
||||
|
||||
func _update_name_label() -> void:
|
||||
if not is_instance_valid(_nameLabel):
|
||||
return
|
||||
var personaName := str(cafeCompanionData.get("persona_name", "")).strip_edges()
|
||||
if not personaName.is_empty():
|
||||
_configure_cafe_name_label(personaName)
|
||||
_nameLabel.visible = true
|
||||
return
|
||||
_configure_default_name_label()
|
||||
_nameLabel.text = username if not username.strip_edges().is_empty() else "玩家"
|
||||
_nameLabel.visible = _settings_bool("show_name_always", false)
|
||||
|
||||
func _configure_cafe_name_label(personaName: String) -> void:
|
||||
var displayName := personaName.strip_edges()
|
||||
if displayName.is_empty():
|
||||
displayName = "陪伴机器人"
|
||||
var visualWidth := _cafe_name_label_visual_width(displayName)
|
||||
var renderWidth := ceili(float(visualWidth) / CAFE_NAME_LABEL_RENDER_SCALE)
|
||||
var renderHeight := ceili(float(CAFE_NAME_LABEL_VISUAL_HEIGHT) / CAFE_NAME_LABEL_RENDER_SCALE)
|
||||
|
||||
_nameLabel.theme = WORLD_TEXT_THEME
|
||||
_nameLabel.text = displayName
|
||||
_nameLabel.z_index = 30
|
||||
_nameLabel.scale = Vector2.ONE * CAFE_NAME_LABEL_RENDER_SCALE
|
||||
_nameLabel.position = Vector2(float(visualWidth) * -0.5, CAFE_NAME_LABEL_OFFSET_Y)
|
||||
_nameLabel.custom_minimum_size = Vector2(renderWidth, renderHeight)
|
||||
_nameLabel.size = _nameLabel.custom_minimum_size
|
||||
_nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_nameLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_nameLabel.clip_text = true
|
||||
_nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
_nameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_nameLabel.add_theme_color_override("font_color", Color(0.12, 0.20, 0.24, 1.0))
|
||||
_nameLabel.add_theme_color_override("font_shadow_color", Color(1.0, 1.0, 1.0, 0.85))
|
||||
_nameLabel.add_theme_constant_override("shadow_offset_x", 0)
|
||||
_nameLabel.add_theme_constant_override("shadow_offset_y", 1)
|
||||
_nameLabel.add_theme_font_size_override("font_size", CAFE_NAME_LABEL_FONT_SIZE)
|
||||
_nameLabel.add_theme_stylebox_override("normal", _create_cafe_name_label_style())
|
||||
|
||||
func _configure_default_name_label() -> void:
|
||||
_nameLabel.theme = null
|
||||
_nameLabel.position = NAME_LABEL_OFFSET
|
||||
_nameLabel.scale = Vector2.ONE
|
||||
_nameLabel.custom_minimum_size = Vector2(140, 28)
|
||||
_nameLabel.size = _nameLabel.custom_minimum_size
|
||||
_nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_nameLabel.vertical_alignment = VERTICAL_ALIGNMENT_TOP
|
||||
_nameLabel.clip_text = false
|
||||
_nameLabel.text_overrun_behavior = TextServer.OVERRUN_NO_TRIMMING
|
||||
_nameLabel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_nameLabel.add_theme_color_override("font_color", Color(0.188, 0.294, 0.424))
|
||||
_nameLabel.remove_theme_color_override("font_shadow_color")
|
||||
_nameLabel.remove_theme_constant_override("shadow_offset_x")
|
||||
_nameLabel.remove_theme_constant_override("shadow_offset_y")
|
||||
_nameLabel.add_theme_font_size_override("font_size", 14)
|
||||
_nameLabel.add_theme_stylebox_override("normal", _create_name_label_style())
|
||||
|
||||
func _cafe_name_label_visual_width(displayName: String) -> int:
|
||||
var estimatedWidth := displayName.length() * CAFE_NAME_LABEL_VISUAL_CHAR_WIDTH + 28
|
||||
return clampi(estimatedWidth, CAFE_NAME_LABEL_VISUAL_MIN_WIDTH, CAFE_NAME_LABEL_VISUAL_MAX_WIDTH)
|
||||
|
||||
func _configure_cafe_companion_target() -> void:
|
||||
if cafeCompanionData.is_empty():
|
||||
_clear_cafe_companion_target()
|
||||
return
|
||||
|
||||
if not is_instance_valid(_cafeCompanionTarget):
|
||||
_cafeCompanionTarget = CafeCompanionTarget.new()
|
||||
_cafeCompanionTarget.name = "CafeCompanionTarget"
|
||||
_cafeCompanionTarget.position = Vector2(0, -58)
|
||||
_cafeCompanionTarget.collision_layer = 2
|
||||
_cafeCompanionTarget.collision_mask = 0
|
||||
add_child(_cafeCompanionTarget)
|
||||
|
||||
var shapeNode := CollisionShape2D.new()
|
||||
shapeNode.name = "CollisionShape2D"
|
||||
var shape := RectangleShape2D.new()
|
||||
shape.size = Vector2(112, 148)
|
||||
shapeNode.shape = shape
|
||||
_cafeCompanionTarget.add_child(shapeNode)
|
||||
|
||||
_cafeCompanionTarget.servicePointId = str(cafeCompanionData.get("service_point_id", "")).strip_edges()
|
||||
_cafeCompanionTarget.companionId = str(cafeCompanionData.get("companion_id", "")).strip_edges()
|
||||
_cafeCompanionTarget.companionType = str(cafeCompanionData.get("companion_type", "hired_player")).strip_edges()
|
||||
_cafeCompanionTarget.personaName = str(cafeCompanionData.get("persona_name", "")).strip_edges()
|
||||
_cafeCompanionTarget.ownerUserId = str(cafeCompanionData.get("owner_user_id", "")).strip_edges()
|
||||
_cafeCompanionTarget.employmentEndsAt = str(cafeCompanionData.get("employment_ends_at", "")).strip_edges()
|
||||
_cafeCompanionTarget.selfTarget = false
|
||||
_cafeCompanionTarget.input_pickable = true
|
||||
|
||||
var shapeNode := _cafeCompanionTarget.get_node_or_null("CollisionShape2D") as CollisionShape2D
|
||||
if shapeNode != null:
|
||||
shapeNode.disabled = false
|
||||
|
||||
func _clear_cafe_companion_target() -> void:
|
||||
if is_instance_valid(_cafeCompanionTarget):
|
||||
_cafeCompanionTarget.queue_free()
|
||||
_cafeCompanionTarget = null
|
||||
|
||||
func _normalize_cafe_companion_data(value: Variant) -> Dictionary:
|
||||
if not (value is Dictionary):
|
||||
return {}
|
||||
|
||||
var raw: Dictionary = value
|
||||
var servicePointId := str(raw.get("servicePointId", raw.get("service_point_id", ""))).strip_edges()
|
||||
var companionId := str(raw.get("companionId", raw.get("companion_id", ""))).strip_edges()
|
||||
var personaName := str(raw.get("personaName", raw.get("persona_name", ""))).strip_edges()
|
||||
if servicePointId.is_empty() or companionId.is_empty() or personaName.is_empty():
|
||||
return {}
|
||||
|
||||
return {
|
||||
"cafe_id": str(raw.get("cafeId", raw.get("cafe_id", "whale_cafe"))).strip_edges(),
|
||||
"service_point_id": servicePointId,
|
||||
"companion_id": companionId,
|
||||
"companion_type": str(raw.get("companionType", raw.get("companion_type", "hired_player"))).strip_edges(),
|
||||
"persona_name": personaName,
|
||||
"employment_ends_at": str(raw.get("employmentEndsAt", raw.get("employment_ends_at", ""))).strip_edges(),
|
||||
"owner_user_id": str(raw.get("ownerUserId", raw.get("owner_user_id", ""))).strip_edges(),
|
||||
}
|
||||
|
||||
func _settings_bool(key: String, defaultValue: bool) -> bool:
|
||||
var settingsManager := get_node_or_null("/root/SettingsManager")
|
||||
if settingsManager != null and settingsManager.has_method("get_bool"):
|
||||
return bool(settingsManager.call("get_bool", key))
|
||||
return defaultValue
|
||||
|
||||
func _create_name_label_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(1.0, 1.0, 1.0, 0.74)
|
||||
style.corner_radius_top_left = 10
|
||||
style.corner_radius_top_right = 10
|
||||
style.corner_radius_bottom_left = 10
|
||||
style.corner_radius_bottom_right = 10
|
||||
style.content_margin_left = 8
|
||||
style.content_margin_right = 8
|
||||
style.content_margin_top = 4
|
||||
style.content_margin_bottom = 4
|
||||
return style
|
||||
|
||||
func _create_cafe_name_label_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(1.0, 0.988, 0.955, 0.96)
|
||||
style.border_color = Color(0.18, 0.34, 0.38, 0.92)
|
||||
style.border_width_left = 2
|
||||
style.border_width_top = 2
|
||||
style.border_width_right = 2
|
||||
style.border_width_bottom = 2
|
||||
style.corner_radius_top_left = 12
|
||||
style.corner_radius_top_right = 12
|
||||
style.corner_radius_bottom_left = 12
|
||||
style.corner_radius_bottom_right = 12
|
||||
style.content_margin_left = 12
|
||||
style.content_margin_top = 4
|
||||
style.content_margin_right = 12
|
||||
style.content_margin_bottom = 4
|
||||
style.shadow_color = Color(0.05, 0.08, 0.09, 0.18)
|
||||
style.shadow_size = 4
|
||||
style.shadow_offset = Vector2(0, 2)
|
||||
return style
|
||||
1
scenes/characters/RemotePlayer.gd.uid
Normal file
1
scenes/characters/RemotePlayer.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ciip727q52j4w
|
||||
68
scenes/characters/cafe_whale_barista_npc.tscn
Normal file
68
scenes/characters/cafe_whale_barista_npc.tscn
Normal file
@@ -0,0 +1,68 @@
|
||||
[gd_scene load_steps=7 format=3]
|
||||
|
||||
[ext_resource type="Texture2D" path="res://assets/characters/cafe_whale_barista_npc_640.png" id="1_texture"]
|
||||
[ext_resource type="Script" path="res://scenes/characters/NPCController.gd" id="2_script"]
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="Shape_CafeWhaleNpc"]
|
||||
size = Vector2(48, 24)
|
||||
|
||||
[sub_resource type="Animation" id="Animation_Reset"]
|
||||
length = 0.001
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0),
|
||||
"transitions": PackedFloat32Array(1),
|
||||
"update": 1,
|
||||
"values": [0]
|
||||
}
|
||||
|
||||
[sub_resource type="Animation" id="Animation_Idle"]
|
||||
resource_name = "idle"
|
||||
length = 1.2
|
||||
loop_mode = 1
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0.0333333, 0.26666665, 0.4666667, 0.8, 1),
|
||||
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||
"update": 1,
|
||||
"values": [2, 1, 0, 4, 5]
|
||||
}
|
||||
|
||||
[sub_resource type="AnimationLibrary" id="AnimationLibrary_CafeWhaleNpc"]
|
||||
_data = {
|
||||
&"RESET": SubResource("Animation_Reset"),
|
||||
&"idle": SubResource("Animation_Idle")
|
||||
}
|
||||
|
||||
[node name="CafeWhaleBaristaNpc" type="CharacterBody2D"]
|
||||
script = ExtResource("2_script")
|
||||
npcName = "海盐拿铁"
|
||||
dialogue = "欢迎来到鲸鱼咖啡馆!我是海盐拿铁,买好陪聊时间后可以和我慢慢聊。"
|
||||
|
||||
[node name="Sprite2D" type="Sprite2D" parent="."]
|
||||
texture_filter = 2
|
||||
position = Vector2(0, -58)
|
||||
scale = Vector2(0.72, 0.72)
|
||||
texture = ExtResource("1_texture")
|
||||
hframes = 4
|
||||
vframes = 4
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||
light_mask = 5
|
||||
visibility_layer = 5
|
||||
shape = SubResource("Shape_CafeWhaleNpc")
|
||||
|
||||
[node name="AnimationPlayer" type="AnimationPlayer" parent="."]
|
||||
libraries = {
|
||||
&"": SubResource("AnimationLibrary_CafeWhaleNpc")
|
||||
}
|
||||
67
scenes/characters/crayfish_npc.tscn
Normal file
67
scenes/characters/crayfish_npc.tscn
Normal file
@@ -0,0 +1,67 @@
|
||||
[gd_scene load_steps=7 format=3]
|
||||
|
||||
[ext_resource type="Texture2D" path="res://assets/characters/crayfish_npc_256_256.png" id="1_texture"]
|
||||
[ext_resource type="Script" path="res://scenes/characters/NPCController.gd" id="2_script"]
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="1_shape"]
|
||||
size = Vector2(44, 22)
|
||||
|
||||
[sub_resource type="Animation" id="2_reset"]
|
||||
length = 0.001
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0),
|
||||
"transitions": PackedFloat32Array(1),
|
||||
"update": 1,
|
||||
"values": [0]
|
||||
}
|
||||
|
||||
[sub_resource type="Animation" id="3_idle"]
|
||||
resource_name = "idle"
|
||||
length = 1.2
|
||||
loop_mode = 1
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0.0333333, 0.26666665, 0.4666667, 0.8, 1),
|
||||
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||
"update": 1,
|
||||
"values": [2, 1, 0, 4, 5]
|
||||
}
|
||||
|
||||
[sub_resource type="AnimationLibrary" id="4_library"]
|
||||
_data = {
|
||||
&"RESET": SubResource("2_reset"),
|
||||
&"idle": SubResource("3_idle")
|
||||
}
|
||||
|
||||
[node name="CrayfishNpc" type="CharacterBody2D"]
|
||||
script = ExtResource("2_script")
|
||||
npcName = "虾小满"
|
||||
dialogue = "欢迎来到 WhaleTown!我是虾小满,负责看着喷泉边的水路和码头消息。想找热闹的地方,顺着水边走就对啦。"
|
||||
|
||||
[node name="Sprite2D" type="Sprite2D" parent="."]
|
||||
texture_filter = 2
|
||||
scale = Vector2(0.65, 0.65)
|
||||
texture = ExtResource("1_texture")
|
||||
hframes = 4
|
||||
vframes = 4
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||
light_mask = 5
|
||||
visibility_layer = 5
|
||||
shape = SubResource("1_shape")
|
||||
|
||||
[node name="AnimationPlayer" type="AnimationPlayer" parent="."]
|
||||
libraries = {
|
||||
&"": SubResource("4_library")
|
||||
}
|
||||
66
scenes/characters/npc.tscn
Normal file
66
scenes/characters/npc.tscn
Normal file
@@ -0,0 +1,66 @@
|
||||
[gd_scene load_steps=7 format=3]
|
||||
|
||||
[ext_resource type="Texture2D" path="res://assets/characters/npc_286_241.png" id="1_2r34a"]
|
||||
[ext_resource type="Script" path="res://scenes/characters/NPCController.gd" id="1_script"]
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_npc"]
|
||||
size = Vector2(48, 24)
|
||||
|
||||
[sub_resource type="Animation" id="Animation_2r34a"]
|
||||
length = 0.001
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0),
|
||||
"transitions": PackedFloat32Array(1),
|
||||
"update": 1,
|
||||
"values": [0]
|
||||
}
|
||||
|
||||
[sub_resource type="Animation" id="Animation_idle"]
|
||||
resource_name = "idle"
|
||||
length = 1.2
|
||||
loop_mode = 1
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0.0333333, 0.26666665, 0.4666667, 0.8, 1),
|
||||
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||
"update": 1,
|
||||
"values": [2, 1, 0, 4, 5]
|
||||
}
|
||||
|
||||
[sub_resource type="AnimationLibrary" id="AnimationLibrary_npc"]
|
||||
_data = {
|
||||
&"RESET": SubResource("Animation_2r34a"),
|
||||
&"idle": SubResource("Animation_idle")
|
||||
}
|
||||
|
||||
[node name="NPC" type="CharacterBody2D"]
|
||||
position = Vector2(-8, 0)
|
||||
script = ExtResource("1_script")
|
||||
|
||||
[node name="Sprite2D" type="Sprite2D" parent="."]
|
||||
texture_filter = 2
|
||||
scale = Vector2(0.72, 0.72)
|
||||
texture = ExtResource("1_2r34a")
|
||||
hframes = 4
|
||||
vframes = 4
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||
light_mask = 5
|
||||
visibility_layer = 5
|
||||
shape = SubResource("RectangleShape2D_npc")
|
||||
|
||||
[node name="AnimationPlayer" type="AnimationPlayer" parent="."]
|
||||
libraries = {
|
||||
&"": SubResource("AnimationLibrary_npc")
|
||||
}
|
||||
181
scenes/characters/player.tscn
Normal file
181
scenes/characters/player.tscn
Normal file
@@ -0,0 +1,181 @@
|
||||
[gd_scene load_steps=13 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/characters/PlayerController.gd" id="1_script"]
|
||||
[ext_resource type="Texture2D" path="res://assets/characters/player_pixel_spritesheet.png" id="2_texture"]
|
||||
|
||||
[sub_resource type="CapsuleShape2D" id="CapsuleShape2D_1"]
|
||||
radius = 21.0
|
||||
height = 48.0
|
||||
|
||||
[sub_resource type="Animation" id="Animation_idle_down"]
|
||||
resource_name = "idle_down"
|
||||
length = 0.1
|
||||
loop_mode = 1
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0),
|
||||
"transitions": PackedFloat32Array(1),
|
||||
"update": 1,
|
||||
"values": [0]
|
||||
}
|
||||
|
||||
[sub_resource type="Animation" id="Animation_idle_left"]
|
||||
resource_name = "idle_left"
|
||||
length = 0.1
|
||||
loop_mode = 1
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0),
|
||||
"transitions": PackedFloat32Array(1),
|
||||
"update": 1,
|
||||
"values": [12]
|
||||
}
|
||||
|
||||
[sub_resource type="Animation" id="Animation_idle_right"]
|
||||
resource_name = "idle_right"
|
||||
length = 0.1
|
||||
loop_mode = 1
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0),
|
||||
"transitions": PackedFloat32Array(1),
|
||||
"update": 1,
|
||||
"values": [8]
|
||||
}
|
||||
|
||||
[sub_resource type="Animation" id="Animation_idle_up"]
|
||||
resource_name = "idle_up"
|
||||
length = 0.1
|
||||
loop_mode = 1
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0),
|
||||
"transitions": PackedFloat32Array(1),
|
||||
"update": 1,
|
||||
"values": [4]
|
||||
}
|
||||
|
||||
[sub_resource type="Animation" id="Animation_walk_down"]
|
||||
resource_name = "walk_down"
|
||||
length = 0.8
|
||||
loop_mode = 1
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6),
|
||||
"transitions": PackedFloat32Array(1, 1, 1, 1),
|
||||
"update": 1,
|
||||
"values": [0, 1, 2, 3]
|
||||
}
|
||||
|
||||
[sub_resource type="Animation" id="Animation_walk_left"]
|
||||
resource_name = "walk_left"
|
||||
length = 0.8
|
||||
loop_mode = 1
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6),
|
||||
"transitions": PackedFloat32Array(1, 1, 1, 1),
|
||||
"update": 1,
|
||||
"values": [12, 13, 14, 15]
|
||||
}
|
||||
|
||||
[sub_resource type="Animation" id="Animation_walk_right"]
|
||||
resource_name = "walk_right"
|
||||
length = 0.8
|
||||
loop_mode = 1
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6),
|
||||
"transitions": PackedFloat32Array(1, 1, 1, 1),
|
||||
"update": 1,
|
||||
"values": [8, 9, 10, 11]
|
||||
}
|
||||
|
||||
[sub_resource type="Animation" id="Animation_walk_up"]
|
||||
resource_name = "walk_up"
|
||||
length = 0.8
|
||||
loop_mode = 1
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6),
|
||||
"transitions": PackedFloat32Array(1, 1, 1, 1),
|
||||
"update": 1,
|
||||
"values": [4, 5, 6, 7]
|
||||
}
|
||||
|
||||
[sub_resource type="AnimationLibrary" id="AnimationLibrary_1"]
|
||||
_data = {
|
||||
&"idle_down": SubResource("Animation_idle_down"),
|
||||
&"idle_left": SubResource("Animation_idle_left"),
|
||||
&"idle_right": SubResource("Animation_idle_right"),
|
||||
&"idle_up": SubResource("Animation_idle_up"),
|
||||
&"walk_down": SubResource("Animation_walk_down"),
|
||||
&"walk_left": SubResource("Animation_walk_left"),
|
||||
&"walk_right": SubResource("Animation_walk_right"),
|
||||
&"walk_up": SubResource("Animation_walk_up")
|
||||
}
|
||||
|
||||
[node name="Player" type="CharacterBody2D"]
|
||||
script = ExtResource("1_script")
|
||||
|
||||
[node name="Sprite2D" type="Sprite2D" parent="."]
|
||||
texture_filter = 2
|
||||
position = Vector2(0, -31)
|
||||
scale = Vector2(0.5, 0.5)
|
||||
texture = ExtResource("2_texture")
|
||||
hframes = 4
|
||||
vframes = 4
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||
position = Vector2(2, -24)
|
||||
shape = SubResource("CapsuleShape2D_1")
|
||||
|
||||
[node name="AnimationPlayer" type="AnimationPlayer" parent="."]
|
||||
libraries = {
|
||||
&"": SubResource("AnimationLibrary_1")
|
||||
}
|
||||
|
||||
[node name="Camera2D" type="Camera2D" parent="."]
|
||||
zoom = Vector2(2, 2)
|
||||
|
||||
[node name="RayCast2D" type="RayCast2D" parent="."]
|
||||
177
scenes/characters/remote_player.tscn
Normal file
177
scenes/characters/remote_player.tscn
Normal file
@@ -0,0 +1,177 @@
|
||||
[gd_scene load_steps=13 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/characters/RemotePlayer.gd" id="1_mu86i"]
|
||||
[ext_resource type="Texture2D" path="res://assets/characters/player_pixel_spritesheet.png" id="2_7oc7u"]
|
||||
|
||||
[sub_resource type="CapsuleShape2D" id="CapsuleShape2D_1"]
|
||||
radius = 21.0
|
||||
height = 48.0
|
||||
|
||||
[sub_resource type="Animation" id="Animation_idle_down"]
|
||||
resource_name = "idle_down"
|
||||
length = 0.1
|
||||
loop_mode = 1
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0),
|
||||
"transitions": PackedFloat32Array(1),
|
||||
"update": 1,
|
||||
"values": [0]
|
||||
}
|
||||
|
||||
[sub_resource type="Animation" id="Animation_idle_left"]
|
||||
resource_name = "idle_left"
|
||||
length = 0.1
|
||||
loop_mode = 1
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0),
|
||||
"transitions": PackedFloat32Array(1),
|
||||
"update": 1,
|
||||
"values": [12]
|
||||
}
|
||||
|
||||
[sub_resource type="Animation" id="Animation_idle_right"]
|
||||
resource_name = "idle_right"
|
||||
length = 0.1
|
||||
loop_mode = 1
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0),
|
||||
"transitions": PackedFloat32Array(1),
|
||||
"update": 1,
|
||||
"values": [8]
|
||||
}
|
||||
|
||||
[sub_resource type="Animation" id="Animation_idle_up"]
|
||||
resource_name = "idle_up"
|
||||
length = 0.1
|
||||
loop_mode = 1
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0),
|
||||
"transitions": PackedFloat32Array(1),
|
||||
"update": 1,
|
||||
"values": [4]
|
||||
}
|
||||
|
||||
[sub_resource type="Animation" id="Animation_walk_down"]
|
||||
resource_name = "walk_down"
|
||||
length = 0.8
|
||||
loop_mode = 1
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6),
|
||||
"transitions": PackedFloat32Array(1, 1, 1, 1),
|
||||
"update": 1,
|
||||
"values": [0, 1, 2, 3]
|
||||
}
|
||||
|
||||
[sub_resource type="Animation" id="Animation_walk_left"]
|
||||
resource_name = "walk_left"
|
||||
length = 0.8
|
||||
loop_mode = 1
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6),
|
||||
"transitions": PackedFloat32Array(1, 1, 1, 1),
|
||||
"update": 1,
|
||||
"values": [12, 13, 14, 15]
|
||||
}
|
||||
|
||||
[sub_resource type="Animation" id="Animation_walk_right"]
|
||||
resource_name = "walk_right"
|
||||
length = 0.8
|
||||
loop_mode = 1
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6),
|
||||
"transitions": PackedFloat32Array(1, 1, 1, 1),
|
||||
"update": 1,
|
||||
"values": [8, 9, 10, 11]
|
||||
}
|
||||
|
||||
[sub_resource type="Animation" id="Animation_walk_up"]
|
||||
resource_name = "walk_up"
|
||||
length = 0.8
|
||||
loop_mode = 1
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0, 0.2, 0.4, 0.6),
|
||||
"transitions": PackedFloat32Array(1, 1, 1, 1),
|
||||
"update": 1,
|
||||
"values": [4, 5, 6, 7]
|
||||
}
|
||||
|
||||
[sub_resource type="AnimationLibrary" id="AnimationLibrary_1"]
|
||||
_data = {
|
||||
&"idle_down": SubResource("Animation_idle_down"),
|
||||
&"idle_left": SubResource("Animation_idle_left"),
|
||||
&"idle_right": SubResource("Animation_idle_right"),
|
||||
&"idle_up": SubResource("Animation_idle_up"),
|
||||
&"walk_down": SubResource("Animation_walk_down"),
|
||||
&"walk_left": SubResource("Animation_walk_left"),
|
||||
&"walk_right": SubResource("Animation_walk_right"),
|
||||
&"walk_up": SubResource("Animation_walk_up")
|
||||
}
|
||||
|
||||
[node name="RemotePlayer" type="CharacterBody2D"]
|
||||
script = ExtResource("1_mu86i")
|
||||
|
||||
[node name="Sprite2D" type="Sprite2D" parent="."]
|
||||
texture_filter = 2
|
||||
position = Vector2(0, -31)
|
||||
scale = Vector2(0.5, 0.5)
|
||||
texture = ExtResource("2_7oc7u")
|
||||
hframes = 4
|
||||
vframes = 4
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||
position = Vector2(2, -24)
|
||||
shape = SubResource("CapsuleShape2D_1")
|
||||
disabled = true
|
||||
|
||||
[node name="AnimationPlayer" type="AnimationPlayer" parent="."]
|
||||
libraries = {
|
||||
&"": SubResource("AnimationLibrary_1")
|
||||
}
|
||||
19
scenes/prefabs/items/DatawhaleHonorBoard.gd
Normal file
19
scenes/prefabs/items/DatawhaleHonorBoard.gd
Normal file
@@ -0,0 +1,19 @@
|
||||
extends Area2D
|
||||
class_name DatawhaleHonorBoard
|
||||
|
||||
const RANKING_PANEL_SCENE: PackedScene = preload("res://scenes/ui/datawhale_honor_ranking_panel.tscn")
|
||||
const RANKING_PANEL_NAME: String = "DatawhaleHonorRankingPanel"
|
||||
const INTERACTION_COLLISION_LAYER: int = 2
|
||||
|
||||
func _ready() -> void:
|
||||
collision_layer = INTERACTION_COLLISION_LAYER
|
||||
collision_mask = 0
|
||||
|
||||
func interact() -> void:
|
||||
var root: Window = get_tree().root
|
||||
if root.has_node(RANKING_PANEL_NAME):
|
||||
return
|
||||
|
||||
var panel := RANKING_PANEL_SCENE.instantiate()
|
||||
panel.name = RANKING_PANEL_NAME
|
||||
root.add_child(panel)
|
||||
1
scenes/prefabs/items/DatawhaleHonorBoard.gd.uid
Normal file
1
scenes/prefabs/items/DatawhaleHonorBoard.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://btaw7myuo7107
|
||||
19
scenes/prefabs/items/NoticeBoard.gd
Normal file
19
scenes/prefabs/items/NoticeBoard.gd
Normal file
@@ -0,0 +1,19 @@
|
||||
extends Area2D
|
||||
class_name NoticeBoard
|
||||
|
||||
const NOTICE_DIALOG_SCENE: PackedScene = preload("res://scenes/ui/notice_dialog.tscn")
|
||||
const NOTICE_DIALOG_NAME: String = "NoticeDialog"
|
||||
const INTERACTION_COLLISION_LAYER: int = 2
|
||||
|
||||
func _ready() -> void:
|
||||
collision_layer = INTERACTION_COLLISION_LAYER
|
||||
collision_mask = 0
|
||||
|
||||
func interact() -> void:
|
||||
var root: Window = get_tree().root
|
||||
if root.has_node(NOTICE_DIALOG_NAME):
|
||||
return
|
||||
|
||||
var dialog := NOTICE_DIALOG_SCENE.instantiate()
|
||||
dialog.name = NOTICE_DIALOG_NAME
|
||||
root.add_child(dialog)
|
||||
1
scenes/prefabs/items/NoticeBoard.gd.uid
Normal file
1
scenes/prefabs/items/NoticeBoard.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bq3mpki50mt01
|
||||
19
scenes/prefabs/items/WelcomeBoard.gd
Normal file
19
scenes/prefabs/items/WelcomeBoard.gd
Normal file
@@ -0,0 +1,19 @@
|
||||
extends Area2D
|
||||
class_name WelcomeBoard
|
||||
|
||||
const WELCOME_DIALOG_SCENE: PackedScene = preload("res://scenes/ui/welcome_dialog.tscn")
|
||||
const WELCOME_DIALOG_NAME: String = "WelcomeDialog"
|
||||
const INTERACTION_COLLISION_LAYER: int = 2
|
||||
|
||||
func _ready() -> void:
|
||||
collision_layer = INTERACTION_COLLISION_LAYER
|
||||
collision_mask = 0
|
||||
|
||||
func interact() -> void:
|
||||
var root: Window = get_tree().root
|
||||
if root.has_node(WELCOME_DIALOG_NAME):
|
||||
return
|
||||
|
||||
var dialog := WELCOME_DIALOG_SCENE.instantiate()
|
||||
dialog.name = WELCOME_DIALOG_NAME
|
||||
root.add_child(dialog)
|
||||
1
scenes/prefabs/items/WelcomeBoard.gd.uid
Normal file
1
scenes/prefabs/items/WelcomeBoard.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c3rnonmmf53u4
|
||||
369
scenes/prefabs/ui/ChatMessage.gd
Normal file
369
scenes/prefabs/ui/ChatMessage.gd
Normal file
@@ -0,0 +1,369 @@
|
||||
extends PanelContainer
|
||||
|
||||
# ============================================================================
|
||||
# ChatMessage.gd - 聊天消息气泡组件
|
||||
# ============================================================================
|
||||
# 显示单条聊天消息的 UI 组件
|
||||
#
|
||||
# 核心职责:
|
||||
# - 显示消息发送者、内容、时间戳
|
||||
# - 区分自己和他人的消息样式
|
||||
# - 自动格式化时间戳
|
||||
#
|
||||
# 使用方式:
|
||||
# var message := chat_message_scene.instantiate()
|
||||
# message.set_message("PlayerName", "Hello!", timestamp, false)
|
||||
#
|
||||
# 注意事项:
|
||||
# - 使用 @onready 缓存节点引用
|
||||
# - 最大宽度限制为 400 像素
|
||||
# ============================================================================
|
||||
|
||||
class_name ChatMessage
|
||||
|
||||
# ============================================================================
|
||||
# 导出参数
|
||||
# ============================================================================
|
||||
|
||||
# 最大宽度(像素)
|
||||
@export var max_width: int = 400
|
||||
|
||||
const MIN_BUBBLE_TEXT_WIDTH: float = 86.0
|
||||
const BUBBLE_HORIZONTAL_MARGIN: float = 28.0
|
||||
const HEADER_GAP_WIDTH: float = 8.0
|
||||
const AVATAR_SIZE: float = 40.0
|
||||
const AVATAR_CORNER_RADIUS: int = 12
|
||||
|
||||
# ============================================================================
|
||||
# 节点引用
|
||||
# ============================================================================
|
||||
|
||||
# 用户名标签
|
||||
var username_label: Label
|
||||
|
||||
# 时间戳标签
|
||||
var timestamp_label: Label
|
||||
|
||||
# 内容标签
|
||||
var content_label: RichTextLabel
|
||||
|
||||
# 用户信息容器
|
||||
var user_info_container: HBoxContainer
|
||||
|
||||
# 头像节点
|
||||
var left_avatar_panel: PanelContainer
|
||||
var right_avatar_panel: PanelContainer
|
||||
var left_avatar_label: Label
|
||||
var right_avatar_label: Label
|
||||
var message_row: HBoxContainer
|
||||
var bubble_panel: PanelContainer
|
||||
var text_container: VBoxContainer
|
||||
|
||||
# ============================================================================
|
||||
# 生命周期方法
|
||||
# ============================================================================
|
||||
|
||||
func _ready() -> void:
|
||||
_cache_node_refs()
|
||||
|
||||
func _cache_node_refs() -> void:
|
||||
if not username_label:
|
||||
username_label = get_node_or_null("MessageRow/BubblePanel/TextContainer/HeaderRow/UsernameLabel")
|
||||
if not timestamp_label:
|
||||
timestamp_label = get_node_or_null("MessageRow/BubblePanel/TextContainer/HeaderRow/TimestampLabel")
|
||||
if not content_label:
|
||||
content_label = get_node_or_null("MessageRow/BubblePanel/TextContainer/ContentLabel")
|
||||
if not user_info_container:
|
||||
user_info_container = get_node_or_null("MessageRow/BubblePanel/TextContainer/HeaderRow")
|
||||
if not left_avatar_panel:
|
||||
left_avatar_panel = get_node_or_null("MessageRow/LeftAvatarPanel")
|
||||
if not right_avatar_panel:
|
||||
right_avatar_panel = get_node_or_null("MessageRow/RightAvatarPanel")
|
||||
if not left_avatar_label:
|
||||
left_avatar_label = get_node_or_null("MessageRow/LeftAvatarPanel/AvatarLabel")
|
||||
if not right_avatar_label:
|
||||
right_avatar_label = get_node_or_null("MessageRow/RightAvatarPanel/AvatarLabel")
|
||||
if not message_row:
|
||||
message_row = get_node_or_null("MessageRow")
|
||||
if not bubble_panel:
|
||||
bubble_panel = get_node_or_null("MessageRow/BubblePanel")
|
||||
if not text_container:
|
||||
text_container = get_node_or_null("MessageRow/BubblePanel/TextContainer")
|
||||
|
||||
_configure_avatar(left_avatar_panel, left_avatar_label)
|
||||
_configure_avatar(right_avatar_panel, right_avatar_label)
|
||||
|
||||
# 内容换行与自适应高度
|
||||
if content_label:
|
||||
content_label.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
content_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
content_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
content_label.fit_content = true
|
||||
content_label.scroll_active = false
|
||||
|
||||
# ============================================================================
|
||||
# 成员变量
|
||||
# ============================================================================
|
||||
|
||||
# 是否为自己发送的消息
|
||||
var _is_self: bool = false
|
||||
|
||||
# ============================================================================
|
||||
# 公共 API
|
||||
# ============================================================================
|
||||
|
||||
# 设置消息内容
|
||||
#
|
||||
# 参数:
|
||||
# from_user: String - 发送者用户名
|
||||
# content: String - 消息内容
|
||||
# timestamp: float - Unix 时间戳
|
||||
# is_self: bool - 是否为自己发送的消息(默认 false)
|
||||
#
|
||||
# 使用示例:
|
||||
# message.set_message("Alice", "Hello!", 1703500800.0, false)
|
||||
func set_message(from_user: String, content: String, timestamp: float, is_self: bool = false) -> void:
|
||||
_is_self = is_self
|
||||
_cache_node_refs()
|
||||
|
||||
var safe_from_user := from_user
|
||||
if safe_from_user.strip_edges().is_empty():
|
||||
safe_from_user = "我" if is_self else "玩家"
|
||||
|
||||
# 设置用户名(带空值检查)
|
||||
if username_label:
|
||||
username_label.text = safe_from_user
|
||||
else:
|
||||
push_error("ChatMessage: username_label is null!")
|
||||
return
|
||||
|
||||
var avatar_text := _get_avatar_text(safe_from_user, is_self)
|
||||
if left_avatar_label:
|
||||
left_avatar_label.text = avatar_text
|
||||
if right_avatar_label:
|
||||
right_avatar_label.text = avatar_text
|
||||
|
||||
# 设置内容
|
||||
if content_label:
|
||||
content_label.clear() # 清除默认文本和所有内容
|
||||
content_label.append_text(content) # 作为纯文本追加,避免 BBCode 解析导致内容不显示
|
||||
else:
|
||||
push_error("ChatMessage: content_label is null!")
|
||||
return
|
||||
|
||||
# 设置时间戳
|
||||
if timestamp_label:
|
||||
timestamp_label.text = _format_timestamp(timestamp)
|
||||
else:
|
||||
push_error("ChatMessage: timestamp_label is null!")
|
||||
return
|
||||
|
||||
_update_bubble_width(safe_from_user, content)
|
||||
|
||||
# 应用样式
|
||||
_apply_style()
|
||||
|
||||
# ============================================================================
|
||||
# 内部方法 - 样式处理
|
||||
# ============================================================================
|
||||
|
||||
# 应用样式(自己和他人的消息不同)
|
||||
func _apply_style() -> void:
|
||||
if not username_label or not timestamp_label or not user_info_container:
|
||||
return
|
||||
|
||||
# 重要:设置垂直 size flags 让 Panel 适应内容高度
|
||||
size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
|
||||
if _is_self:
|
||||
if message_row:
|
||||
message_row.alignment = BoxContainer.ALIGNMENT_END
|
||||
user_info_container.alignment = BoxContainer.ALIGNMENT_END
|
||||
_set_avatar_side(true)
|
||||
|
||||
# 设置面板样式
|
||||
if bubble_panel:
|
||||
bubble_panel.add_theme_stylebox_override("panel", _get_self_style())
|
||||
if right_avatar_panel:
|
||||
right_avatar_panel.add_theme_stylebox_override("panel", _get_avatar_style(true))
|
||||
_apply_selected_avatar(right_avatar_panel, right_avatar_label, username_label.text)
|
||||
|
||||
# 设置文字颜色 - ID使用金色 #FFD700
|
||||
username_label.add_theme_color_override("font_color", Color(0.258824, 0.627451, 0.913725))
|
||||
timestamp_label.add_theme_color_override("font_color", Color(0.560784, 0.639216, 0.733333))
|
||||
if right_avatar_label:
|
||||
right_avatar_label.add_theme_color_override("font_color", Color(1, 1, 1))
|
||||
if content_label:
|
||||
content_label.add_theme_color_override("default_color", Color(0.188, 0.294, 0.424))
|
||||
else:
|
||||
if message_row:
|
||||
message_row.alignment = BoxContainer.ALIGNMENT_BEGIN
|
||||
user_info_container.alignment = BoxContainer.ALIGNMENT_BEGIN
|
||||
_set_avatar_side(false)
|
||||
|
||||
# 设置面板样式
|
||||
if bubble_panel:
|
||||
bubble_panel.add_theme_stylebox_override("panel", _get_other_style())
|
||||
if left_avatar_panel:
|
||||
left_avatar_panel.add_theme_stylebox_override("panel", _get_avatar_style(false))
|
||||
|
||||
# 设置文字颜色 - ID使用蓝色 #69c0ff
|
||||
username_label.add_theme_color_override("font_color", Color(0.207843, 0.427451, 0.686275))
|
||||
timestamp_label.add_theme_color_override("font_color", Color(0.560784, 0.639216, 0.733333))
|
||||
if left_avatar_label:
|
||||
left_avatar_label.add_theme_color_override("font_color", Color(1, 1, 1))
|
||||
if content_label:
|
||||
content_label.add_theme_color_override("default_color", Color(0.27451, 0.407843, 0.541176))
|
||||
|
||||
func _set_avatar_side(is_self: bool) -> void:
|
||||
if left_avatar_panel:
|
||||
left_avatar_panel.visible = not is_self
|
||||
if right_avatar_panel:
|
||||
right_avatar_panel.visible = is_self
|
||||
|
||||
func _configure_avatar(panel: PanelContainer, label: Label) -> void:
|
||||
if panel:
|
||||
panel.custom_minimum_size = Vector2(AVATAR_SIZE, AVATAR_SIZE)
|
||||
panel.size = Vector2(AVATAR_SIZE, AVATAR_SIZE)
|
||||
panel.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
panel.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
panel.clip_contents = true
|
||||
if label:
|
||||
label.custom_minimum_size = Vector2(AVATAR_SIZE, AVATAR_SIZE)
|
||||
label.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
|
||||
func _update_bubble_width(from_user: String, content: String) -> void:
|
||||
var maxTextWidth := float(max_width) - BUBBLE_HORIZONTAL_MARGIN
|
||||
var contentWidth := _measure_label_text_width(content_label, content, 14.0)
|
||||
var headerWidth := _measure_label_text_width(username_label, from_user, 16.0) + _measure_label_text_width(timestamp_label, timestamp_label.text, 13.0) + HEADER_GAP_WIDTH
|
||||
var textWidth: float = clamp(max(contentWidth, headerWidth, MIN_BUBBLE_TEXT_WIDTH), MIN_BUBBLE_TEXT_WIDTH, maxTextWidth)
|
||||
|
||||
if username_label:
|
||||
username_label.custom_minimum_size.x = min(_measure_label_text_width(username_label, from_user, 16.0), textWidth)
|
||||
if timestamp_label:
|
||||
timestamp_label.custom_minimum_size.x = _measure_label_text_width(timestamp_label, timestamp_label.text, 13.0)
|
||||
if content_label:
|
||||
content_label.custom_minimum_size.x = textWidth
|
||||
if bubble_panel:
|
||||
bubble_panel.custom_minimum_size.x = textWidth + BUBBLE_HORIZONTAL_MARGIN
|
||||
|
||||
func _measure_label_text_width(label: Control, text: String, fallback_width: float) -> float:
|
||||
if label != null:
|
||||
var font := label.get_theme_font("font")
|
||||
if font != null:
|
||||
var fontSize := label.get_theme_font_size("font_size")
|
||||
if fontSize <= 0:
|
||||
fontSize = int(fallback_width)
|
||||
var measured := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, fontSize).x
|
||||
if measured > 0.0:
|
||||
return measured + 2.0
|
||||
|
||||
var width := 0.0
|
||||
for index in range(text.length()):
|
||||
var code := text.unicode_at(index)
|
||||
if code <= 0x7f:
|
||||
width += fallback_width * 0.58
|
||||
else:
|
||||
width += fallback_width
|
||||
return width + 2.0
|
||||
|
||||
# 获取自己消息的样式
|
||||
func _get_self_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.878431, 0.94902, 1.0, 0.96)
|
||||
style.border_width_left = 1
|
||||
style.border_width_top = 1
|
||||
style.border_width_right = 1
|
||||
style.border_width_bottom = 1
|
||||
style.border_color = Color(0.64, 0.82, 0.95, 0.78)
|
||||
style.corner_radius_top_left = 14
|
||||
style.corner_radius_top_right = 8
|
||||
style.corner_radius_bottom_left = 14
|
||||
style.corner_radius_bottom_right = 14
|
||||
style.content_margin_left = 14
|
||||
style.content_margin_right = 14
|
||||
style.content_margin_top = 9
|
||||
style.content_margin_bottom = 9
|
||||
return style
|
||||
|
||||
# 获取他人消息的样式
|
||||
func _get_other_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(1, 1, 1, 0.96)
|
||||
style.border_width_left = 1
|
||||
style.border_width_top = 1
|
||||
style.border_width_right = 1
|
||||
style.border_width_bottom = 1
|
||||
style.border_color = Color(0.78, 0.85, 0.92, 0.72)
|
||||
style.corner_radius_top_left = 8
|
||||
style.corner_radius_top_right = 14
|
||||
style.corner_radius_bottom_left = 14
|
||||
style.corner_radius_bottom_right = 14
|
||||
style.content_margin_left = 14
|
||||
style.content_margin_right = 14
|
||||
style.content_margin_top = 9
|
||||
style.content_margin_bottom = 9
|
||||
return style
|
||||
|
||||
func _get_avatar_style(is_self: bool) -> StyleBoxFlat:
|
||||
if is_self:
|
||||
var appearanceManager := get_node_or_null("/root/AppearanceManager")
|
||||
if appearanceManager != null and appearanceManager.has_method("create_avatar_style") and appearanceManager.has_method("get_selected_avatar"):
|
||||
var avatar: Variant = appearanceManager.call("get_selected_avatar")
|
||||
if avatar is Dictionary:
|
||||
return appearanceManager.call("create_avatar_style", avatar as Dictionary, AVATAR_CORNER_RADIUS)
|
||||
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.361, 0.690, 0.914) if is_self else Color(0.62, 0.75, 0.86)
|
||||
style.corner_radius_top_left = AVATAR_CORNER_RADIUS
|
||||
style.corner_radius_top_right = AVATAR_CORNER_RADIUS
|
||||
style.corner_radius_bottom_left = AVATAR_CORNER_RADIUS
|
||||
style.corner_radius_bottom_right = AVATAR_CORNER_RADIUS
|
||||
style.border_width_left = 1
|
||||
style.border_width_top = 1
|
||||
style.border_width_right = 1
|
||||
style.border_width_bottom = 1
|
||||
style.border_color = Color(1, 1, 1, 0.82)
|
||||
return style
|
||||
|
||||
func _get_avatar_text(from_user: String, is_self: bool) -> String:
|
||||
if is_self:
|
||||
var appearanceManager := get_node_or_null("/root/AppearanceManager")
|
||||
if appearanceManager != null and appearanceManager.has_method("has_custom_avatar") and bool(appearanceManager.call("has_custom_avatar")):
|
||||
return ""
|
||||
if appearanceManager != null and appearanceManager.has_method("get_avatar_label"):
|
||||
return str(appearanceManager.call("get_avatar_label"))
|
||||
return "我"
|
||||
var trimmed := from_user.strip_edges()
|
||||
if trimmed.is_empty():
|
||||
return "鲸"
|
||||
return trimmed.substr(0, 1)
|
||||
|
||||
func _apply_selected_avatar(panel: PanelContainer, label: Label, fallbackText: String) -> void:
|
||||
var appearanceManager := get_node_or_null("/root/AppearanceManager")
|
||||
if appearanceManager != null and appearanceManager.has_method("apply_avatar_to_panel"):
|
||||
appearanceManager.call("apply_avatar_to_panel", panel, label, "", fallbackText)
|
||||
|
||||
# ============================================================================
|
||||
# 内部方法 - 工具函数
|
||||
# ============================================================================
|
||||
|
||||
# 格式化时间戳
|
||||
#
|
||||
# 参数:
|
||||
# timestamp: float - Unix 时间戳
|
||||
#
|
||||
# 返回值:
|
||||
# String - 格式化的时间字符串
|
||||
func _format_timestamp(timestamp: float) -> String:
|
||||
if timestamp == 0:
|
||||
return ""
|
||||
|
||||
var datetime := Time.get_datetime_dict_from_unix_time(timestamp)
|
||||
|
||||
# 格式化为 HH:MM
|
||||
return "%02d:%02d" % [datetime.hour, datetime.minute]
|
||||
1
scenes/prefabs/ui/ChatMessage.gd.uid
Normal file
1
scenes/prefabs/ui/ChatMessage.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cyftwn8xuo8so
|
||||
82
scenes/prefabs/ui/ChatMessage.tscn
Normal file
82
scenes/prefabs/ui/ChatMessage.tscn
Normal file
@@ -0,0 +1,82 @@
|
||||
[gd_scene load_steps=3 format=3 uid="uid://dqx8k3n8yqjvu"]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/prefabs/ui/ChatMessage.gd" id="1"]
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_panel"]
|
||||
|
||||
[node name="ChatMessage" type="PanelContainer"]
|
||||
custom_minimum_size = Vector2(0, 58)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
script = ExtResource("1")
|
||||
theme_override_styles/panel = SubResource("StyleBoxEmpty_panel")
|
||||
|
||||
[node name="MessageRow" type="HBoxContainer" parent="."]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
alignment = 0
|
||||
|
||||
[node name="LeftAvatarPanel" type="PanelContainer" parent="MessageRow"]
|
||||
custom_minimum_size = Vector2(40, 40)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 0
|
||||
|
||||
[node name="AvatarLabel" type="Label" parent="MessageRow/LeftAvatarPanel"]
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(40, 40)
|
||||
theme_override_colors/font_color = Color(1, 1, 1, 1)
|
||||
theme_override_font_sizes/font_size = 16
|
||||
text = "鲸"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="BubblePanel" type="PanelContainer" parent="MessageRow"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 0
|
||||
|
||||
[node name="TextContainer" type="VBoxContainer" parent="MessageRow/BubblePanel"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 2
|
||||
|
||||
[node name="HeaderRow" type="HBoxContainer" parent="MessageRow/BubblePanel/TextContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="UsernameLabel" type="Label" parent="MessageRow/BubblePanel/TextContainer/HeaderRow"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_colors/font_color = Color(0.207843, 0.427451, 0.686275, 1)
|
||||
theme_override_font_sizes/font_size = 16
|
||||
text = "鲸鱼居民"
|
||||
text_overrun_behavior = 3
|
||||
|
||||
[node name="TimestampLabel" type="Label" parent="MessageRow/BubblePanel/TextContainer/HeaderRow"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.560784, 0.639216, 0.733333, 1)
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "12:34"
|
||||
|
||||
[node name="ContentLabel" type="RichTextLabel" parent="MessageRow/BubblePanel/TextContainer"]
|
||||
custom_minimum_size = Vector2(0, 26)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
fit_content = true
|
||||
scroll_active = false
|
||||
autowrap_mode = 3
|
||||
bbcode_enabled = false
|
||||
theme_override_colors/default_color = Color(0.388235, 0.478431, 0.584314, 1)
|
||||
theme_override_font_sizes/normal_font_size = 14
|
||||
|
||||
[node name="RightAvatarPanel" type="PanelContainer" parent="MessageRow"]
|
||||
custom_minimum_size = Vector2(40, 40)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 0
|
||||
|
||||
[node name="AvatarLabel" type="Label" parent="MessageRow/RightAvatarPanel"]
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(40, 40)
|
||||
theme_override_colors/font_color = Color(1, 1, 1, 1)
|
||||
theme_override_font_sizes/font_size = 16
|
||||
text = "我"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
2373
scenes/ui/AuthScene.gd
Normal file
2373
scenes/ui/AuthScene.gd
Normal file
File diff suppressed because it is too large
Load Diff
1
scenes/ui/AuthScene.gd.uid
Normal file
1
scenes/ui/AuthScene.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://drvhq1pc3630q
|
||||
12
scenes/ui/AuthScene.tscn
Normal file
12
scenes/ui/AuthScene.tscn
Normal file
@@ -0,0 +1,12 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://dkmpv7bgmot2e"]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/ui/AuthScene.gd" id="1_auth"]
|
||||
|
||||
[node name="AuthScene" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_auth")
|
||||
63
scenes/ui/BubbleSendButton.gd
Normal file
63
scenes/ui/BubbleSendButton.gd
Normal file
@@ -0,0 +1,63 @@
|
||||
extends Button
|
||||
|
||||
# 世界频道气泡发送按钮,线条风格与顶部 HUD 快捷入口保持一致。
|
||||
|
||||
const LINE_COLOR: Color = Color(0.592157, 0.72549, 0.835294, 0.88)
|
||||
const HOVER_COLOR: Color = Color(0.392157, 0.717647, 0.94902, 0.96)
|
||||
const LINE_WIDTH: float = 0.95
|
||||
const DETAIL_WIDTH: float = 0.82
|
||||
const BASE_SIZE: float = 27.0
|
||||
|
||||
var _iconScale: float = 1.0
|
||||
var _iconOffset: Vector2 = Vector2.ZERO
|
||||
|
||||
func _ready() -> void:
|
||||
text = ""
|
||||
queue_redraw()
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_THEME_CHANGED:
|
||||
queue_redraw()
|
||||
|
||||
func _draw() -> void:
|
||||
_iconScale = min(size.x, size.y) / BASE_SIZE
|
||||
_iconOffset = (size - Vector2(BASE_SIZE, BASE_SIZE) * _iconScale) * 0.5
|
||||
|
||||
var lineColor := HOVER_COLOR if is_hovered() else LINE_COLOR
|
||||
_draw_round_rect(Rect2(6.0, 7.0, 15.0, 11.0), 3.2, lineColor, LINE_WIDTH)
|
||||
_draw_polyline([
|
||||
Vector2(10.2, 18.0),
|
||||
Vector2(8.2, 21.2),
|
||||
Vector2(14.4, 18.0),
|
||||
], lineColor, LINE_WIDTH)
|
||||
_draw_line(Vector2(9.2, 11.7), Vector2(17.6, 11.7), lineColor, DETAIL_WIDTH)
|
||||
_draw_line(Vector2(9.2, 14.6), Vector2(14.8, 14.6), lineColor, DETAIL_WIDTH)
|
||||
|
||||
func _draw_round_rect(rect: Rect2, radius: float, color: Color, width: float) -> void:
|
||||
var left := rect.position.x
|
||||
var top := rect.position.y
|
||||
var right := rect.end.x
|
||||
var bottom := rect.end.y
|
||||
_draw_line(Vector2(left + radius, top), Vector2(right - radius, top), color, width)
|
||||
_draw_line(Vector2(right, top + radius), Vector2(right, bottom - radius), color, width)
|
||||
_draw_line(Vector2(right - radius, bottom), Vector2(left + radius, bottom), color, width)
|
||||
_draw_line(Vector2(left, bottom - radius), Vector2(left, top + radius), color, width)
|
||||
_draw_arc(Vector2(left + radius, top + radius), radius, PI, PI * 1.5, 8, color, width)
|
||||
_draw_arc(Vector2(right - radius, top + radius), radius, PI * 1.5, TAU, 8, color, width)
|
||||
_draw_arc(Vector2(right - radius, bottom - radius), radius, 0.0, PI * 0.5, 8, color, width)
|
||||
_draw_arc(Vector2(left + radius, bottom - radius), radius, PI * 0.5, PI, 8, color, width)
|
||||
|
||||
func _draw_polyline(points: Array[Vector2], color: Color, width: float) -> void:
|
||||
var packed: PackedVector2Array = []
|
||||
for point in points:
|
||||
packed.append(_p(point))
|
||||
draw_polyline(packed, color, width, true)
|
||||
|
||||
func _draw_line(from: Vector2, to: Vector2, color: Color, width: float) -> void:
|
||||
draw_line(_p(from), _p(to), color, width, true)
|
||||
|
||||
func _draw_arc(center: Vector2, radius: float, startAngle: float, endAngle: float, pointCount: int, color: Color, width: float) -> void:
|
||||
draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, color, width, true)
|
||||
|
||||
func _p(point: Vector2) -> Vector2:
|
||||
return _iconOffset + point * _iconScale
|
||||
1
scenes/ui/BubbleSendButton.gd.uid
Normal file
1
scenes/ui/BubbleSendButton.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://w3fq3gc8kar2
|
||||
495
scenes/ui/CafeCompanionPanel.gd
Normal file
495
scenes/ui/CafeCompanionPanel.gd
Normal file
@@ -0,0 +1,495 @@
|
||||
extends Control
|
||||
|
||||
# ============================================================================
|
||||
# CafeCompanionPanel.gd - 咖啡店陪伴机器人面板
|
||||
# ============================================================================
|
||||
# 点击咖啡店陪伴机器人后,先展示付费时长选择;购买成功后才进入聊天。
|
||||
# ============================================================================
|
||||
|
||||
@onready var chatFrame: PanelContainer = %CafeCompanionFrame
|
||||
@onready var titleLabel: Label = %TitleLabel
|
||||
@onready var subtitleLabel: Label = %SubtitleLabel
|
||||
@onready var productList: VBoxContainer = %ProductList
|
||||
@onready var purchaseButton: Button = %PurchaseButton
|
||||
@onready var statusLabel: Label = %StatusLabel
|
||||
@onready var chatHistory: ScrollContainer = %ChatHistory
|
||||
@onready var messageList: VBoxContainer = %MessageList
|
||||
@onready var chatInput: LineEdit = %ChatInput
|
||||
@onready var sendButton: Button = %SendButton
|
||||
@onready var closeButton: Button = %CloseButton
|
||||
|
||||
var _currentSessionId: String = ""
|
||||
var _currentTarget: Dictionary = {}
|
||||
var _products: Array = []
|
||||
var _selectedMinutes: int = 0
|
||||
var _waitingForReply: bool = false
|
||||
var _resignTarget: Dictionary = {}
|
||||
var _resignDialog: Control
|
||||
var _resignLabel: Label
|
||||
var _resignConfirmButton: Button
|
||||
var _resignCancelButton: Button
|
||||
|
||||
func _ready() -> void:
|
||||
chatFrame.visible = false
|
||||
purchaseButton.pressed.connect(_on_purchase_pressed)
|
||||
sendButton.pressed.connect(_on_send_pressed)
|
||||
closeButton.pressed.connect(_on_close_pressed)
|
||||
chatInput.text_submitted.connect(_on_input_submitted)
|
||||
_build_resign_dialog()
|
||||
_connect_events()
|
||||
_set_chat_enabled(false)
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var eventSystem := get_node_or_null("/root/EventSystem")
|
||||
if eventSystem != null:
|
||||
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_SELECTED, _on_cafe_companion_selected, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_SELF_SELECTED, _on_cafe_companion_self_selected, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_PRODUCTS_READY, _on_products_ready, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_CHAT_TIME_PURCHASED, _on_chat_time_purchased, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_MESSAGE_RECEIVED, _on_message_received, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_EMPLOYMENT_RESIGNED, _on_employment_resigned, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_ERROR_OCCURRED, _on_companion_error, self)
|
||||
|
||||
func _connect_events() -> void:
|
||||
var eventSystem := get_node_or_null("/root/EventSystem")
|
||||
if eventSystem == null:
|
||||
push_warning("CafeCompanionPanel: EventSystem autoload is not available.")
|
||||
return
|
||||
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_SELECTED, _on_cafe_companion_selected, self)
|
||||
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_SELF_SELECTED, _on_cafe_companion_self_selected, self)
|
||||
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_PRODUCTS_READY, _on_products_ready, self)
|
||||
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_CHAT_TIME_PURCHASED, _on_chat_time_purchased, self)
|
||||
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_MESSAGE_RECEIVED, _on_message_received, self)
|
||||
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_EMPLOYMENT_RESIGNED, _on_employment_resigned, self)
|
||||
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_ERROR_OCCURRED, _on_companion_error, self)
|
||||
|
||||
func _on_cafe_companion_selected(data: Dictionary) -> void:
|
||||
_currentTarget = data.duplicate(true)
|
||||
_currentSessionId = ""
|
||||
_waitingForReply = false
|
||||
_selectedMinutes = 0
|
||||
_clear_messages()
|
||||
|
||||
titleLabel.text = _format_target_title(data)
|
||||
subtitleLabel.text = _format_target_subtitle(data)
|
||||
statusLabel.text = "请选择陪聊时长"
|
||||
purchaseButton.text = "购买聊天时间"
|
||||
purchaseButton.disabled = true
|
||||
chatFrame.visible = true
|
||||
_set_chat_enabled(false)
|
||||
|
||||
var manager := _get_cafe_companion_manager()
|
||||
if manager == null or not manager.has_method("get_chat_time_products"):
|
||||
_set_error("CafeCompanionManager 未加载")
|
||||
return
|
||||
manager.call("get_chat_time_products")
|
||||
|
||||
func _on_cafe_companion_self_selected(data: Dictionary) -> void:
|
||||
_resignTarget = data.duplicate(true)
|
||||
_resignLabel.text = _format_resign_message(data)
|
||||
_resignDialog.visible = true
|
||||
_resignDialog.modulate.a = 0.0
|
||||
var tween := create_tween()
|
||||
tween.tween_property(_resignDialog, "modulate:a", 1.0, 0.12)
|
||||
|
||||
func _on_employment_resigned(data: Dictionary) -> void:
|
||||
_hide_resign_dialog()
|
||||
_set_error(_format_resign_result(data))
|
||||
chatFrame.visible = false
|
||||
|
||||
func _on_products_ready(data: Dictionary) -> void:
|
||||
if not chatFrame.visible:
|
||||
return
|
||||
var productsVariant: Variant = data.get("products", [])
|
||||
_products = productsVariant if productsVariant is Array else []
|
||||
_render_products()
|
||||
if _products.is_empty():
|
||||
statusLabel.text = "暂时没有可购买的陪聊时长"
|
||||
else:
|
||||
statusLabel.text = "点击时长后购买,购买成功才会进入聊天"
|
||||
|
||||
func _on_chat_time_purchased(data: Dictionary) -> void:
|
||||
var sessionData: Dictionary = data
|
||||
var sessionVariant: Variant = data.get("session", {})
|
||||
if sessionVariant is Dictionary:
|
||||
sessionData = sessionVariant
|
||||
|
||||
var servicePointId := str(sessionData.get("service_point_id", ""))
|
||||
if not _currentTarget.is_empty() and servicePointId != str(_currentTarget.get("service_point_id", "")):
|
||||
return
|
||||
|
||||
_currentSessionId = str(sessionData.get("session_id", ""))
|
||||
_waitingForReply = false
|
||||
_set_chat_enabled(true)
|
||||
statusLabel.text = _format_purchase_status(data, sessionData)
|
||||
|
||||
var companionVariant: Variant = sessionData.get("companion", {})
|
||||
if companionVariant is Dictionary:
|
||||
var companion: Dictionary = companionVariant
|
||||
titleLabel.text = str(companion.get("persona_name", titleLabel.text)).strip_edges()
|
||||
|
||||
_clear_messages()
|
||||
var messagesVariant: Variant = sessionData.get("messages", [])
|
||||
if messagesVariant is Array and not messagesVariant.is_empty():
|
||||
for messageVariant in messagesVariant:
|
||||
if messageVariant is Dictionary:
|
||||
var message: Dictionary = messageVariant
|
||||
_add_message(str(message.get("role", "assistant")), str(message.get("content", "")))
|
||||
else:
|
||||
var welcomeMessage := str(sessionData.get("welcome_message", "")).strip_edges()
|
||||
if not welcomeMessage.is_empty():
|
||||
_add_message("assistant", welcomeMessage)
|
||||
|
||||
chatInput.grab_focus()
|
||||
|
||||
func _on_message_received(data: Dictionary) -> void:
|
||||
if str(data.get("session_id", "")) != _currentSessionId:
|
||||
return
|
||||
_waitingForReply = false
|
||||
_set_chat_enabled(true)
|
||||
statusLabel.text = _format_remaining_status(data)
|
||||
|
||||
var messageVariant: Variant = data.get("assistant_message", {})
|
||||
if messageVariant is Dictionary:
|
||||
var message: Dictionary = messageVariant
|
||||
_add_message("assistant", str(message.get("content", "")))
|
||||
|
||||
func _on_companion_error(data: Dictionary) -> void:
|
||||
if not chatFrame.visible and not (_resignDialog != null and _resignDialog.visible):
|
||||
return
|
||||
if _resignDialog != null and _resignDialog.visible and is_instance_valid(_resignLabel):
|
||||
_resignLabel.text = str(data.get("message", "咖啡店陪聊暂时不可用"))
|
||||
_resignConfirmButton.disabled = false
|
||||
return
|
||||
_set_error(str(data.get("message", "咖啡店陪聊暂时不可用")))
|
||||
|
||||
func _render_products() -> void:
|
||||
for child in productList.get_children():
|
||||
child.queue_free()
|
||||
|
||||
for productVariant in _products:
|
||||
if not (productVariant is Dictionary):
|
||||
continue
|
||||
var product: Dictionary = productVariant
|
||||
var minutes := int(product.get("minutes", 0))
|
||||
if minutes <= 0:
|
||||
continue
|
||||
var button := Button.new()
|
||||
button.toggle_mode = true
|
||||
button.focus_mode = Control.FOCUS_NONE
|
||||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
button.text = _format_product_label(product)
|
||||
button.pressed.connect(func() -> void:
|
||||
_select_product(minutes)
|
||||
)
|
||||
productList.add_child(button)
|
||||
|
||||
func _select_product(minutes: int) -> void:
|
||||
_selectedMinutes = minutes
|
||||
for child in productList.get_children():
|
||||
var button := child as Button
|
||||
if button != null:
|
||||
button.button_pressed = button.text.begins_with("%d分钟" % minutes)
|
||||
purchaseButton.disabled = false
|
||||
purchaseButton.text = "购买 %d 分钟" % minutes
|
||||
|
||||
func _on_purchase_pressed() -> void:
|
||||
if _selectedMinutes <= 0:
|
||||
statusLabel.text = "请先选择陪聊时长"
|
||||
return
|
||||
var manager := _get_cafe_companion_manager()
|
||||
if manager == null or not manager.has_method("purchase_chat_time"):
|
||||
_set_error("CafeCompanionManager 未加载")
|
||||
return
|
||||
purchaseButton.disabled = true
|
||||
statusLabel.text = "正在购买陪聊时长..."
|
||||
manager.call("purchase_chat_time", _currentTarget, _selectedMinutes)
|
||||
|
||||
func _on_send_pressed() -> void:
|
||||
_send_current_message()
|
||||
|
||||
func _on_input_submitted(_text: String) -> void:
|
||||
_send_current_message()
|
||||
|
||||
func _send_current_message() -> void:
|
||||
if _waitingForReply:
|
||||
return
|
||||
var content := chatInput.text.strip_edges()
|
||||
if content.is_empty():
|
||||
return
|
||||
if _currentSessionId.is_empty():
|
||||
_set_error("请先购买陪聊时长")
|
||||
return
|
||||
|
||||
chatInput.clear()
|
||||
_add_message("user", content)
|
||||
_waitingForReply = true
|
||||
_set_chat_enabled(false)
|
||||
statusLabel.text = "对方正在回复..."
|
||||
|
||||
var manager := _get_cafe_companion_manager()
|
||||
if manager == null or not manager.has_method("send_chat_message"):
|
||||
_set_error("CafeCompanionManager 未加载")
|
||||
return
|
||||
manager.call("send_chat_message", _currentSessionId, content)
|
||||
|
||||
func _on_close_pressed() -> void:
|
||||
chatFrame.visible = false
|
||||
_waitingForReply = false
|
||||
chatInput.release_focus()
|
||||
|
||||
func _confirm_resign() -> void:
|
||||
var servicePointId := str(_resignTarget.get("service_point_id", "")).strip_edges()
|
||||
if servicePointId.is_empty():
|
||||
_set_error("离职目标不存在")
|
||||
return
|
||||
var manager := _get_cafe_companion_manager()
|
||||
if manager == null or not manager.has_method("resign_employment"):
|
||||
_set_error("CafeCompanionManager 未加载")
|
||||
return
|
||||
_resignConfirmButton.disabled = true
|
||||
_resignLabel.text = "正在办理离职..."
|
||||
manager.call("resign_employment", servicePointId)
|
||||
|
||||
func _hide_resign_dialog() -> void:
|
||||
if is_instance_valid(_resignDialog):
|
||||
_resignDialog.visible = false
|
||||
if is_instance_valid(_resignConfirmButton):
|
||||
_resignConfirmButton.disabled = false
|
||||
|
||||
func _set_error(message: String) -> void:
|
||||
_waitingForReply = false
|
||||
_set_chat_enabled(not _currentSessionId.is_empty())
|
||||
purchaseButton.disabled = _selectedMinutes <= 0
|
||||
statusLabel.text = message
|
||||
|
||||
func _set_chat_enabled(enabled: bool) -> void:
|
||||
chatInput.editable = enabled
|
||||
sendButton.disabled = not enabled
|
||||
|
||||
func _clear_messages() -> void:
|
||||
for child in messageList.get_children():
|
||||
child.queue_free()
|
||||
|
||||
func _add_message(role: String, content: String) -> void:
|
||||
var normalizedContent := content.strip_edges()
|
||||
if normalizedContent.is_empty():
|
||||
return
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
row.alignment = BoxContainer.ALIGNMENT_END if role == "user" else BoxContainer.ALIGNMENT_BEGIN
|
||||
|
||||
var bubble := PanelContainer.new()
|
||||
bubble.custom_minimum_size = Vector2(120, 0)
|
||||
bubble.size_flags_horizontal = 0
|
||||
bubble.add_theme_stylebox_override("panel", _make_bubble_style(role))
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 14)
|
||||
margin.add_theme_constant_override("margin_top", 8)
|
||||
margin.add_theme_constant_override("margin_right", 14)
|
||||
margin.add_theme_constant_override("margin_bottom", 8)
|
||||
|
||||
var label := Label.new()
|
||||
label.text = normalizedContent
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
label.custom_minimum_size = Vector2(300, 0)
|
||||
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
label.add_theme_color_override("font_color", Color(0.16, 0.20, 0.25, 1))
|
||||
label.add_theme_font_size_override("font_size", 16)
|
||||
|
||||
margin.add_child(label)
|
||||
bubble.add_child(margin)
|
||||
row.add_child(bubble)
|
||||
messageList.add_child(row)
|
||||
call_deferred("_scroll_to_bottom")
|
||||
|
||||
func _make_bubble_style(role: String) -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.80, 0.92, 0.98, 1) if role == "user" else Color(1, 1, 1, 0.96)
|
||||
style.border_color = Color(0.55, 0.72, 0.82, 0.35)
|
||||
style.set_border_width_all(1)
|
||||
style.set_corner_radius_all(10)
|
||||
return style
|
||||
|
||||
func _scroll_to_bottom() -> void:
|
||||
if is_instance_valid(chatHistory):
|
||||
chatHistory.scroll_vertical = int(chatHistory.get_v_scroll_bar().max_value)
|
||||
|
||||
func _get_cafe_companion_manager() -> Node:
|
||||
return get_node_or_null("/root/CafeCompanionManager")
|
||||
|
||||
func _build_resign_dialog() -> void:
|
||||
_resignDialog = Control.new()
|
||||
_resignDialog.name = "CafeEmploymentResignDialog"
|
||||
_resignDialog.set_anchors_preset(Control.PRESET_CENTER)
|
||||
_resignDialog.custom_minimum_size = Vector2(460, 240)
|
||||
_resignDialog.offset_left = -230
|
||||
_resignDialog.offset_top = -120
|
||||
_resignDialog.offset_right = 230
|
||||
_resignDialog.offset_bottom = 120
|
||||
_resignDialog.visible = false
|
||||
_resignDialog.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
add_child(_resignDialog)
|
||||
|
||||
var bg := PanelContainer.new()
|
||||
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
bg.add_theme_stylebox_override("panel", _make_dialog_style())
|
||||
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_resignDialog.add_child(bg)
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
margin.add_theme_constant_override("margin_left", 28)
|
||||
margin.add_theme_constant_override("margin_top", 28)
|
||||
margin.add_theme_constant_override("margin_right", 28)
|
||||
margin.add_theme_constant_override("margin_bottom", 24)
|
||||
_resignDialog.add_child(margin)
|
||||
|
||||
var box := VBoxContainer.new()
|
||||
box.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
box.add_theme_constant_override("separation", 18)
|
||||
margin.add_child(box)
|
||||
|
||||
_resignLabel = Label.new()
|
||||
_resignLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_resignLabel.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_resignLabel.add_theme_font_size_override("font_size", 18)
|
||||
_resignLabel.add_theme_color_override("font_color", Color(0.16, 0.22, 0.28, 1))
|
||||
box.add_child(_resignLabel)
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
row.add_theme_constant_override("separation", 12)
|
||||
box.add_child(row)
|
||||
|
||||
_resignCancelButton = _make_dialog_button("继续打工", false)
|
||||
_resignCancelButton.pressed.connect(_hide_resign_dialog)
|
||||
row.add_child(_resignCancelButton)
|
||||
|
||||
_resignConfirmButton = _make_dialog_button("确认离职", true)
|
||||
_resignConfirmButton.pressed.connect(_confirm_resign)
|
||||
row.add_child(_resignConfirmButton)
|
||||
|
||||
func _make_dialog_button(textValue: String, primary: bool) -> Button:
|
||||
var button := Button.new()
|
||||
button.text = textValue
|
||||
button.custom_minimum_size = Vector2(128, 42)
|
||||
button.focus_mode = Control.FOCUS_NONE
|
||||
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
button.add_theme_font_size_override("font_size", 16)
|
||||
button.add_theme_color_override("font_color", Color.WHITE if primary else Color(0.16, 0.22, 0.28, 1))
|
||||
if not primary:
|
||||
button.add_theme_color_override("font_hover_color", Color(0.258824, 0.627451, 0.913725))
|
||||
button.add_theme_color_override("font_pressed_color", Color(0.060, 0.270, 0.540, 1.0))
|
||||
button.add_theme_color_override("font_disabled_color", Color(0.520, 0.600, 0.680, 0.55))
|
||||
button.add_theme_stylebox_override("normal", _make_dialog_button_style(primary, false))
|
||||
button.add_theme_stylebox_override("hover", _make_dialog_button_style(primary, true))
|
||||
button.add_theme_stylebox_override("pressed", _make_dialog_button_style(primary, false))
|
||||
button.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
|
||||
return button
|
||||
|
||||
func _make_dialog_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.982, 0.995, 1.0, 0.98)
|
||||
style.border_color = Color(0.46, 0.62, 0.70, 0.28)
|
||||
style.set_border_width_all(1)
|
||||
style.set_corner_radius_all(16)
|
||||
style.shadow_color = Color(0.06, 0.09, 0.12, 0.22)
|
||||
style.shadow_size = 12
|
||||
style.shadow_offset = Vector2(0, 5)
|
||||
return style
|
||||
|
||||
func _make_dialog_button_style(primary: bool, hover: bool) -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
if primary:
|
||||
style.bg_color = Color(0.78, 0.26, 0.22, 1.0) if not hover else Color(0.88, 0.32, 0.26, 1.0)
|
||||
else:
|
||||
style.bg_color = Color(0.90, 0.96, 0.98, 1.0) if not hover else Color(0.84, 0.93, 0.97, 1.0)
|
||||
style.set_corner_radius_all(10)
|
||||
return style
|
||||
|
||||
func _format_resign_message(data: Dictionary) -> String:
|
||||
var personaName := str(data.get("persona_name", "陪伴机器人")).strip_edges()
|
||||
var endsAt := str(data.get("employment_ends_at", "")).strip_edges()
|
||||
var message := "确认结束「%s」的咖啡店打工?" % personaName
|
||||
if not endsAt.is_empty():
|
||||
message += "\n原定结束时间:%s" % endsAt
|
||||
message += "\n提前离职会按剩余工时扣回部分已获得收益。"
|
||||
return message
|
||||
|
||||
func _format_resign_result(data: Dictionary) -> String:
|
||||
var penaltyVariant: Variant = data.get("penalty", {})
|
||||
if penaltyVariant is Dictionary:
|
||||
var penalty: Dictionary = penaltyVariant
|
||||
var amount := int(penalty.get("amount", 0))
|
||||
if amount > 0:
|
||||
return "已离职,扣回 %d 鲸币收益" % amount
|
||||
return "已结束咖啡店雇佣"
|
||||
|
||||
func _format_target_title(data: Dictionary) -> String:
|
||||
var personaName := str(data.get("persona_name", "陪伴机器人")).strip_edges()
|
||||
return personaName if not personaName.is_empty() else "陪伴机器人"
|
||||
|
||||
func _format_target_subtitle(data: Dictionary) -> String:
|
||||
var servicePointLabel := _format_service_point_label(str(data.get("service_point_id", "")))
|
||||
return "鲸鱼咖啡馆 · %s" % servicePointLabel if not servicePointLabel.is_empty() else "鲸鱼咖啡馆"
|
||||
|
||||
func _format_service_point_label(servicePointId: String) -> String:
|
||||
if servicePointId.begins_with("ServiceIdlePoint"):
|
||||
var pointNumber := _parse_point_number(servicePointId)
|
||||
if pointNumber > 0:
|
||||
return "%s号陪伴位" % _format_chinese_number(pointNumber)
|
||||
return ""
|
||||
|
||||
func _format_product_label(product: Dictionary) -> String:
|
||||
var minutes := int(product.get("minutes", 0))
|
||||
var price := int(product.get("price", 0))
|
||||
return "%d分钟 · %d鲸币" % [minutes, price]
|
||||
|
||||
func _format_purchase_status(data: Dictionary, sessionData: Dictionary) -> String:
|
||||
var price := 0
|
||||
var purchaseVariant: Variant = data.get("purchase", {})
|
||||
if purchaseVariant is Dictionary:
|
||||
price = int((purchaseVariant as Dictionary).get("price", 0))
|
||||
elif data.has("product") and data.get("product") is Dictionary:
|
||||
price = int((data.get("product") as Dictionary).get("price", 0))
|
||||
var remaining := _format_remaining_status(sessionData)
|
||||
if price > 0:
|
||||
return "已购买,花费%d鲸币,%s" % [price, remaining]
|
||||
return "已购买,%s" % remaining
|
||||
|
||||
func _format_remaining_status(data: Dictionary) -> String:
|
||||
var seconds := int(data.get("remaining_seconds", 0))
|
||||
if seconds <= 0:
|
||||
var expiresText := str(data.get("expires_at", "")).strip_edges()
|
||||
return "陪聊时间已开启" if expiresText.is_empty() else "陪聊结束时间 %s" % expiresText
|
||||
var minutes := int(ceil(seconds / 60.0))
|
||||
return "剩余约 %d 分钟" % minutes
|
||||
|
||||
func _parse_point_number(value: String) -> int:
|
||||
var digits := ""
|
||||
for index in range(value.length()):
|
||||
var character := value.substr(index, 1)
|
||||
if character >= "0" and character <= "9":
|
||||
digits += character
|
||||
return int(digits) if not digits.is_empty() else 0
|
||||
|
||||
func _format_chinese_number(value: int) -> String:
|
||||
const CHINESE_DIGITS: Array[String] = [
|
||||
"零", "一", "二", "三", "四", "五", "六", "七", "八", "九"
|
||||
]
|
||||
if value <= 0:
|
||||
return str(value)
|
||||
if value < 10:
|
||||
return CHINESE_DIGITS[value]
|
||||
if value == 10:
|
||||
return "十"
|
||||
if value < 20:
|
||||
return "十%s" % CHINESE_DIGITS[value - 10]
|
||||
if value < 100:
|
||||
var tens := value / 10
|
||||
var ones := value % 10
|
||||
return "%s十%s" % [CHINESE_DIGITS[tens], CHINESE_DIGITS[ones]] if ones > 0 else "%s十" % CHINESE_DIGITS[tens]
|
||||
return str(value)
|
||||
1
scenes/ui/CafeCompanionPanel.gd.uid
Normal file
1
scenes/ui/CafeCompanionPanel.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dm3qcn5gf44au
|
||||
219
scenes/ui/CafeCompanionPanel.tscn
Normal file
219
scenes/ui/CafeCompanionPanel.tscn
Normal file
@@ -0,0 +1,219 @@
|
||||
[gd_scene load_steps=9 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/ui/CafeCompanionPanel.gd" id="1_script"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="Style_Panel"]
|
||||
bg_color = Color(0.96, 0.975, 0.982, 0.97)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.46, 0.62, 0.70, 0.28)
|
||||
corner_radius_top_left = 12
|
||||
corner_radius_top_right = 12
|
||||
corner_radius_bottom_right = 12
|
||||
corner_radius_bottom_left = 12
|
||||
shadow_color = Color(0.06, 0.09, 0.12, 0.22)
|
||||
shadow_size = 12
|
||||
shadow_offset = Vector2(0, 5)
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="Style_Input"]
|
||||
bg_color = Color(1, 1, 1, 0.96)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.55, 0.68, 0.76, 0.36)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
content_margin_left = 12
|
||||
content_margin_top = 4
|
||||
content_margin_right = 12
|
||||
content_margin_bottom = 4
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="Style_InputLine"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="Style_ButtonNormal"]
|
||||
bg_color = Color(0.22, 0.56, 0.78, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="Style_ButtonHover"]
|
||||
bg_color = Color(0.29, 0.64, 0.84, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="Style_ButtonPressed"]
|
||||
bg_color = Color(0.16, 0.45, 0.66, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="Style_Close"]
|
||||
bg_color = Color(0.86, 0.91, 0.94, 0.85)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="Style_Empty"]
|
||||
|
||||
[node name="CafeCompanionPanel" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_script")
|
||||
|
||||
[node name="CafeCompanionFrame" type="PanelContainer" parent="."]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
anchor_left = 1.0
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -560.0
|
||||
offset_top = -442.0
|
||||
offset_right = -28.0
|
||||
offset_bottom = -28.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
mouse_filter = 0
|
||||
theme_override_styles/panel = SubResource("Style_Panel")
|
||||
|
||||
[node name="PanelMargin" type="MarginContainer" parent="CafeCompanionFrame"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 18
|
||||
theme_override_constants/margin_top = 16
|
||||
theme_override_constants/margin_right = 18
|
||||
theme_override_constants/margin_bottom = 16
|
||||
|
||||
[node name="ContentVBox" type="VBoxContainer" parent="CafeCompanionFrame/PanelMargin"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="HeaderRow" type="HBoxContainer" parent="CafeCompanionFrame/PanelMargin/ContentVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="TitleStack" type="VBoxContainer" parent="CafeCompanionFrame/PanelMargin/ContentVBox/HeaderRow"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 2
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="CafeCompanionFrame/PanelMargin/ContentVBox/HeaderRow/TitleStack"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.13, 0.21, 0.27, 1)
|
||||
theme_override_font_sizes/font_size = 20
|
||||
text = "陪伴机器人"
|
||||
text_overrun_behavior = 3
|
||||
|
||||
[node name="SubtitleLabel" type="Label" parent="CafeCompanionFrame/PanelMargin/ContentVBox/HeaderRow/TitleStack"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.40, 0.50, 0.57, 1)
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "鲸鱼咖啡馆"
|
||||
text_overrun_behavior = 3
|
||||
|
||||
[node name="CloseButton" type="Button" parent="CafeCompanionFrame/PanelMargin/ContentVBox/HeaderRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(34, 34)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
mouse_default_cursor_shape = 2
|
||||
theme_override_colors/font_color = Color(0.20, 0.30, 0.36, 1)
|
||||
theme_override_font_sizes/font_size = 18
|
||||
theme_override_styles/normal = SubResource("Style_Close")
|
||||
theme_override_styles/hover = SubResource("Style_Close")
|
||||
theme_override_styles/pressed = SubResource("Style_Close")
|
||||
theme_override_styles/focus = SubResource("Style_Empty")
|
||||
text = "x"
|
||||
|
||||
[node name="ProductList" type="VBoxContainer" parent="CafeCompanionFrame/PanelMargin/ContentVBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="PurchaseButton" type="Button" parent="CafeCompanionFrame/PanelMargin/ContentVBox"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 38)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
mouse_default_cursor_shape = 2
|
||||
theme_override_colors/font_color = Color(1, 1, 1, 1)
|
||||
theme_override_font_sizes/font_size = 16
|
||||
theme_override_styles/normal = SubResource("Style_ButtonNormal")
|
||||
theme_override_styles/hover = SubResource("Style_ButtonHover")
|
||||
theme_override_styles/pressed = SubResource("Style_ButtonPressed")
|
||||
theme_override_styles/focus = SubResource("Style_Empty")
|
||||
text = "购买聊天时间"
|
||||
|
||||
[node name="ChatHistory" type="ScrollContainer" parent="CafeCompanionFrame/PanelMargin/ContentVBox"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 270)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="MessageList" type="VBoxContainer" parent="CafeCompanionFrame/PanelMargin/ContentVBox/ChatHistory"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 1
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="CafeCompanionFrame/PanelMargin/ContentVBox"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 18)
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.48, 0.38, 0.22, 1)
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = ""
|
||||
text_overrun_behavior = 3
|
||||
|
||||
[node name="InputRow" type="HBoxContainer" parent="CafeCompanionFrame/PanelMargin/ContentVBox"]
|
||||
custom_minimum_size = Vector2(0, 42)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="InputShell" type="PanelContainer" parent="CafeCompanionFrame/PanelMargin/ContentVBox/InputRow"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_styles/panel = SubResource("Style_Input")
|
||||
|
||||
[node name="ChatInput" type="LineEdit" parent="CafeCompanionFrame/PanelMargin/ContentVBox/InputRow/InputShell"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 2
|
||||
placeholder_text = "购买后输入消息..."
|
||||
theme_override_colors/font_color = Color(0.16, 0.21, 0.27, 1)
|
||||
theme_override_colors/font_placeholder_color = Color(0.48, 0.56, 0.62, 0.78)
|
||||
theme_override_font_sizes/font_size = 16
|
||||
theme_override_styles/normal = SubResource("Style_InputLine")
|
||||
|
||||
[node name="SendButton" type="Button" parent="CafeCompanionFrame/PanelMargin/ContentVBox/InputRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(72, 40)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
mouse_default_cursor_shape = 2
|
||||
theme_override_colors/font_color = Color(1, 1, 1, 1)
|
||||
theme_override_font_sizes/font_size = 16
|
||||
theme_override_styles/normal = SubResource("Style_ButtonNormal")
|
||||
theme_override_styles/hover = SubResource("Style_ButtonHover")
|
||||
theme_override_styles/pressed = SubResource("Style_ButtonPressed")
|
||||
theme_override_styles/focus = SubResource("Style_Empty")
|
||||
text = "发送"
|
||||
439
scenes/ui/CafeCompanionRecruitmentPanel.gd
Normal file
439
scenes/ui/CafeCompanionRecruitmentPanel.gd
Normal file
@@ -0,0 +1,439 @@
|
||||
extends Control
|
||||
|
||||
# ============================================================================
|
||||
# CafeCompanionRecruitmentPanel.gd - 咖啡店陪伴机器人招聘登记面板
|
||||
# ============================================================================
|
||||
# 点击咖啡馆吧台鲸鱼标识后打开,玩家提交陪伴 Agent 代理配置,
|
||||
# 后端登记为咖啡店陪伴服务点上的被雇佣角色。
|
||||
# ============================================================================
|
||||
|
||||
@onready var recruitmentFrame: PanelContainer = %CafeRecruitmentFrame
|
||||
@onready var titleLabel: Label = %TitleLabel
|
||||
@onready var subtitleLabel: Label = %SubtitleLabel
|
||||
@onready var closeButton: Button = %CloseButton
|
||||
@onready var servicePointOption: OptionButton = %ServicePointOption
|
||||
@onready var employmentDurationOption: OptionButton = %EmploymentDurationOption
|
||||
@onready var personaNameInput: LineEdit = %PersonaNameInput
|
||||
@onready var protocolOption: OptionButton = %ProtocolOption
|
||||
@onready var baseUrlLabel: Label = %BaseUrlLabel
|
||||
@onready var baseUrlInput: LineEdit = %BaseUrlInput
|
||||
@onready var tokenInput: LineEdit = %TokenInput
|
||||
@onready var modelInput: LineEdit = %ModelInput
|
||||
@onready var fetchModelsButton: Button = %FetchModelsButton
|
||||
@onready var modelOption: OptionButton = %ModelOption
|
||||
@onready var personaPromptInput: TextEdit = %PersonaPromptInput
|
||||
@onready var welcomeMessageInput: LineEdit = %WelcomeMessageInput
|
||||
@onready var enabledCheckBox: CheckBox = %EnabledCheckBox
|
||||
@onready var submitButton: Button = %SubmitButton
|
||||
@onready var statusLabel: Label = %StatusLabel
|
||||
|
||||
var _servicePoints: Array = []
|
||||
var _isSubmitting: bool = false
|
||||
var _isFetchingModels: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
recruitmentFrame.visible = false
|
||||
_setup_protocol_options()
|
||||
_setup_employment_duration_options()
|
||||
closeButton.pressed.connect(_on_close_pressed)
|
||||
submitButton.pressed.connect(_on_submit_pressed)
|
||||
fetchModelsButton.pressed.connect(_on_fetch_models_pressed)
|
||||
protocolOption.item_selected.connect(_on_protocol_selected)
|
||||
modelOption.item_selected.connect(_on_model_option_selected)
|
||||
_connect_events()
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var eventSystem := get_node_or_null("/root/EventSystem")
|
||||
if eventSystem == null:
|
||||
return
|
||||
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_RECRUITMENT_SELECTED, _on_recruitment_selected, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_SERVICE_POINTS_READY, _on_service_points_ready, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_AGENT_REGISTERED, _on_agent_registered, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_MODELS_READY, _on_models_ready, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_ERROR_OCCURRED, _on_companion_error, self)
|
||||
|
||||
func _connect_events() -> void:
|
||||
var eventSystem := get_node_or_null("/root/EventSystem")
|
||||
if eventSystem == null:
|
||||
push_warning("CafeCompanionRecruitmentPanel: EventSystem autoload is not available.")
|
||||
return
|
||||
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_RECRUITMENT_SELECTED, _on_recruitment_selected, self)
|
||||
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_SERVICE_POINTS_READY, _on_service_points_ready, self)
|
||||
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_AGENT_REGISTERED, _on_agent_registered, self)
|
||||
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_MODELS_READY, _on_models_ready, self)
|
||||
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_ERROR_OCCURRED, _on_companion_error, self)
|
||||
|
||||
func _on_recruitment_selected(data: Dictionary) -> void:
|
||||
titleLabel.text = str(data.get("title", "咖啡店陪伴招聘"))
|
||||
subtitleLabel.text = "鲸鱼咖啡馆 · 吧台招聘终端"
|
||||
recruitmentFrame.visible = true
|
||||
_isSubmitting = false
|
||||
_isFetchingModels = false
|
||||
submitButton.disabled = false
|
||||
fetchModelsButton.disabled = false
|
||||
_apply_protocol_defaults(true)
|
||||
_set_status("正在读取陪伴位...", false)
|
||||
_request_service_points()
|
||||
|
||||
func _request_service_points() -> void:
|
||||
var manager := _get_cafe_companion_manager()
|
||||
if manager == null or not manager.has_method("get_service_points"):
|
||||
_set_status("CafeCompanionManager 未加载", true)
|
||||
return
|
||||
manager.call("get_service_points", true)
|
||||
|
||||
func _on_service_points_ready(data: Dictionary) -> void:
|
||||
if not recruitmentFrame.visible:
|
||||
return
|
||||
var servicePointsVariant: Variant = data.get("service_points", [])
|
||||
_servicePoints = servicePointsVariant if servicePointsVariant is Array else []
|
||||
_render_service_points()
|
||||
|
||||
func _on_agent_registered(data: Dictionary) -> void:
|
||||
if not recruitmentFrame.visible:
|
||||
return
|
||||
_isSubmitting = false
|
||||
submitButton.disabled = false
|
||||
fetchModelsButton.disabled = false
|
||||
tokenInput.clear()
|
||||
|
||||
var companionVariant: Variant = data.get("companion", {})
|
||||
if companionVariant is Dictionary:
|
||||
var companion: Dictionary = companionVariant
|
||||
_set_status("%s 已登记到 %s" % [
|
||||
str(companion.get("persona_name", "陪伴机器人")),
|
||||
_format_service_point_label(str(companion.get("service_point_id", ""))),
|
||||
], false)
|
||||
else:
|
||||
_set_status("咖啡店陪伴机器人登记成功", false)
|
||||
recruitmentFrame.visible = false
|
||||
release_focus()
|
||||
|
||||
func _on_companion_error(data: Dictionary) -> void:
|
||||
if not recruitmentFrame.visible:
|
||||
return
|
||||
_isSubmitting = false
|
||||
_isFetchingModels = false
|
||||
submitButton.disabled = false
|
||||
fetchModelsButton.disabled = false
|
||||
_set_status(str(data.get("message", "咖啡店招聘暂时不可用")), true)
|
||||
|
||||
func _render_service_points() -> void:
|
||||
servicePointOption.clear()
|
||||
var selectedIndex := -1
|
||||
for pointVariant in _servicePoints:
|
||||
if not (pointVariant is Dictionary):
|
||||
continue
|
||||
var point: Dictionary = pointVariant
|
||||
var pointId := str(point.get("id", "")).strip_edges()
|
||||
if pointId.is_empty():
|
||||
continue
|
||||
var index := servicePointOption.get_item_count()
|
||||
servicePointOption.add_item(_format_service_point_option(point))
|
||||
servicePointOption.set_item_metadata(index, pointId)
|
||||
if selectedIndex < 0 and not _point_has_companion(point):
|
||||
selectedIndex = index
|
||||
|
||||
if servicePointOption.get_item_count() <= 0:
|
||||
_set_status("当前没有开放的咖啡店陪伴位", true)
|
||||
submitButton.disabled = true
|
||||
return
|
||||
|
||||
servicePointOption.select(selectedIndex if selectedIndex >= 0 else 0)
|
||||
submitButton.disabled = _isSubmitting
|
||||
fetchModelsButton.disabled = _isSubmitting
|
||||
_set_status("填写人设和代理配置后可登记", false)
|
||||
|
||||
func _format_service_point_option(point: Dictionary) -> String:
|
||||
var pointId := str(point.get("id", "")).strip_edges()
|
||||
var label := _format_service_point_label(pointId)
|
||||
var companionVariant: Variant = point.get("companion", null)
|
||||
if companionVariant is Dictionary:
|
||||
var companion: Dictionary = companionVariant
|
||||
var personaName := str(companion.get("persona_name", "")).strip_edges()
|
||||
if not personaName.is_empty():
|
||||
return "%s · 当前:%s" % [label, personaName]
|
||||
return "%s · 空位" % label
|
||||
|
||||
func _point_has_companion(point: Dictionary) -> bool:
|
||||
var companionVariant: Variant = point.get("companion", null)
|
||||
return companionVariant is Dictionary and not (companionVariant as Dictionary).is_empty()
|
||||
|
||||
func _on_submit_pressed() -> void:
|
||||
if _isSubmitting:
|
||||
return
|
||||
var payload := _build_payload()
|
||||
if payload.is_empty():
|
||||
return
|
||||
|
||||
var manager := _get_cafe_companion_manager()
|
||||
if manager == null or not manager.has_method("register_employment_agent"):
|
||||
_set_status("CafeCompanionManager 未加载", true)
|
||||
return
|
||||
|
||||
_isSubmitting = true
|
||||
submitButton.disabled = true
|
||||
fetchModelsButton.disabled = true
|
||||
_set_status("正在验证代理并提交雇佣登记...", false)
|
||||
manager.call("register_employment_agent", payload)
|
||||
|
||||
func _on_fetch_models_pressed() -> void:
|
||||
if _isFetchingModels or _isSubmitting:
|
||||
return
|
||||
|
||||
var protocol := _selected_protocol()
|
||||
var baseUrl := baseUrlInput.text.strip_edges()
|
||||
var token := tokenInput.text.strip_edges()
|
||||
if not (baseUrl.begins_with("http://") or baseUrl.begins_with("https://")):
|
||||
_set_status("接口 URL 需要以 http:// 或 https:// 开头", true)
|
||||
baseUrlInput.grab_focus()
|
||||
return
|
||||
if token.is_empty():
|
||||
_set_status("请先填写接口 Token", true)
|
||||
tokenInput.grab_focus()
|
||||
return
|
||||
|
||||
var manager := _get_cafe_companion_manager()
|
||||
if manager == null or not manager.has_method("get_employment_models"):
|
||||
_set_status("CafeCompanionManager 未加载", true)
|
||||
return
|
||||
|
||||
_isFetchingModels = true
|
||||
fetchModelsButton.disabled = true
|
||||
_set_status("正在获取接口可用模型...", false)
|
||||
manager.call("get_employment_models", protocol, baseUrl, token)
|
||||
|
||||
func _on_models_ready(data: Dictionary) -> void:
|
||||
if not recruitmentFrame.visible:
|
||||
return
|
||||
_isFetchingModels = false
|
||||
fetchModelsButton.disabled = _isSubmitting
|
||||
|
||||
var modelsVariant: Variant = data.get("models", [])
|
||||
var models: Array = modelsVariant if modelsVariant is Array else []
|
||||
_render_models(models)
|
||||
|
||||
func _build_payload() -> Dictionary:
|
||||
var servicePointId := _selected_service_point_id()
|
||||
var personaName := personaNameInput.text.strip_edges()
|
||||
var employmentMinutes := _selected_employment_minutes()
|
||||
var protocol := _selected_protocol()
|
||||
var baseUrl := baseUrlInput.text.strip_edges()
|
||||
var token := tokenInput.text.strip_edges()
|
||||
var model := _selected_model_id()
|
||||
var personaPrompt := personaPromptInput.text.strip_edges()
|
||||
var welcomeMessage := welcomeMessageInput.text.strip_edges()
|
||||
|
||||
if servicePointId.is_empty():
|
||||
_set_status("请选择陪伴位", true)
|
||||
return {}
|
||||
if personaName.is_empty():
|
||||
_set_status("请填写人设名称", true)
|
||||
personaNameInput.grab_focus()
|
||||
return {}
|
||||
if employmentMinutes <= 0:
|
||||
_set_status("请选择打工时间", true)
|
||||
return {}
|
||||
if not (baseUrl.begins_with("http://") or baseUrl.begins_with("https://")):
|
||||
_set_status("接口 URL 需要以 http:// 或 https:// 开头", true)
|
||||
baseUrlInput.grab_focus()
|
||||
return {}
|
||||
if token.is_empty():
|
||||
_set_status("请填写接口 Token", true)
|
||||
tokenInput.grab_focus()
|
||||
return {}
|
||||
if model.is_empty():
|
||||
_set_status("请填写模型名称", true)
|
||||
modelInput.grab_focus()
|
||||
return {}
|
||||
if personaPrompt.is_empty():
|
||||
_set_status("请填写人设指令", true)
|
||||
personaPromptInput.grab_focus()
|
||||
return {}
|
||||
|
||||
var payload := {
|
||||
"service_point_id": servicePointId,
|
||||
"persona_name": personaName,
|
||||
"employment_minutes": employmentMinutes,
|
||||
"protocol": protocol,
|
||||
"base_url": baseUrl,
|
||||
"token": token,
|
||||
"model": model,
|
||||
"persona_prompt": personaPrompt,
|
||||
"enabled": enabledCheckBox.button_pressed,
|
||||
}
|
||||
if not welcomeMessage.is_empty():
|
||||
payload["welcome_message"] = welcomeMessage
|
||||
return payload
|
||||
|
||||
func _setup_employment_duration_options() -> void:
|
||||
employmentDurationOption.clear()
|
||||
var durations := [
|
||||
{"label": "30分钟", "minutes": 30},
|
||||
{"label": "1小时", "minutes": 60},
|
||||
{"label": "2小时", "minutes": 120},
|
||||
{"label": "4小时", "minutes": 240},
|
||||
{"label": "8小时", "minutes": 480},
|
||||
]
|
||||
for duration in durations:
|
||||
var index := employmentDurationOption.get_item_count()
|
||||
employmentDurationOption.add_item(str(duration.get("label", "")))
|
||||
employmentDurationOption.set_item_metadata(index, int(duration.get("minutes", 0)))
|
||||
employmentDurationOption.select(1)
|
||||
|
||||
func _selected_employment_minutes() -> int:
|
||||
var selectedIndex := employmentDurationOption.selected
|
||||
if selectedIndex < 0:
|
||||
return 0
|
||||
return int(employmentDurationOption.get_item_metadata(selectedIndex))
|
||||
|
||||
func _selected_service_point_id() -> String:
|
||||
var selectedIndex := servicePointOption.selected
|
||||
if selectedIndex < 0:
|
||||
return ""
|
||||
return str(servicePointOption.get_item_metadata(selectedIndex)).strip_edges()
|
||||
|
||||
func _setup_protocol_options() -> void:
|
||||
protocolOption.clear()
|
||||
protocolOption.add_item("OpenAI", 0)
|
||||
protocolOption.set_item_metadata(0, "openai")
|
||||
protocolOption.add_item("Anthropic", 1)
|
||||
protocolOption.set_item_metadata(1, "anthropic")
|
||||
protocolOption.select(0)
|
||||
_apply_protocol_defaults(true)
|
||||
|
||||
func _on_protocol_selected(_index: int) -> void:
|
||||
_apply_protocol_defaults(true)
|
||||
_set_status("%s 协议已选择,可填写接口并获取模型" % _selected_protocol_label(), false)
|
||||
|
||||
func _selected_protocol() -> String:
|
||||
var selectedIndex := protocolOption.selected
|
||||
if selectedIndex < 0:
|
||||
return "openai"
|
||||
var protocol := str(protocolOption.get_item_metadata(selectedIndex)).strip_edges()
|
||||
return protocol if not protocol.is_empty() else "openai"
|
||||
|
||||
func _selected_protocol_label() -> String:
|
||||
return "Anthropic" if _selected_protocol() == "anthropic" else "OpenAI"
|
||||
|
||||
func _apply_protocol_defaults(clearModelList: bool) -> void:
|
||||
var protocol := _selected_protocol()
|
||||
if protocol == "anthropic":
|
||||
baseUrlLabel.text = "Anthropic URL"
|
||||
baseUrlInput.placeholder_text = "https://api.anthropic.com"
|
||||
modelInput.placeholder_text = "claude-sonnet-4-5-20250929"
|
||||
if modelInput.text.strip_edges().is_empty() or modelInput.text.strip_edges() == "gpt-4o-mini":
|
||||
modelInput.text = "claude-sonnet-4-5-20250929"
|
||||
else:
|
||||
baseUrlLabel.text = "OpenAI-compatible URL"
|
||||
baseUrlInput.placeholder_text = "https://api.example.com/v1"
|
||||
modelInput.placeholder_text = "gpt-4o-mini"
|
||||
if modelInput.text.strip_edges().is_empty() or modelInput.text.strip_edges().begins_with("claude-"):
|
||||
modelInput.text = "gpt-4o-mini"
|
||||
if clearModelList:
|
||||
modelOption.clear()
|
||||
modelOption.visible = false
|
||||
|
||||
func _render_models(models: Array) -> void:
|
||||
modelOption.clear()
|
||||
var currentModel := modelInput.text.strip_edges()
|
||||
var selectedIndex := -1
|
||||
for modelVariant in models:
|
||||
var modelId := _model_id_from_variant(modelVariant)
|
||||
if modelId.is_empty():
|
||||
continue
|
||||
var index := modelOption.get_item_count()
|
||||
modelOption.add_item(_model_label_from_variant(modelVariant, modelId))
|
||||
modelOption.set_item_metadata(index, modelId)
|
||||
if modelId == currentModel:
|
||||
selectedIndex = index
|
||||
|
||||
if modelOption.get_item_count() <= 0:
|
||||
modelOption.visible = false
|
||||
_set_status("接口没有返回可用模型,可继续手动填写模型", true)
|
||||
return
|
||||
|
||||
modelOption.visible = true
|
||||
modelOption.select(selectedIndex if selectedIndex >= 0 else 0)
|
||||
if currentModel.is_empty() or selectedIndex < 0:
|
||||
modelInput.text = str(modelOption.get_item_metadata(modelOption.selected))
|
||||
_set_status("已获取 %d 个可用模型" % modelOption.get_item_count(), false)
|
||||
|
||||
func _on_model_option_selected(index: int) -> void:
|
||||
if index < 0:
|
||||
return
|
||||
modelInput.text = str(modelOption.get_item_metadata(index)).strip_edges()
|
||||
|
||||
func _selected_model_id() -> String:
|
||||
var model := modelInput.text.strip_edges()
|
||||
if not model.is_empty():
|
||||
return model
|
||||
if modelOption.visible and modelOption.selected >= 0:
|
||||
return str(modelOption.get_item_metadata(modelOption.selected)).strip_edges()
|
||||
return ""
|
||||
|
||||
func _model_id_from_variant(modelVariant: Variant) -> String:
|
||||
if modelVariant is String:
|
||||
return str(modelVariant).strip_edges()
|
||||
if modelVariant is Dictionary:
|
||||
var model: Dictionary = modelVariant
|
||||
return str(model.get("id", "")).strip_edges()
|
||||
return ""
|
||||
|
||||
func _model_label_from_variant(modelVariant: Variant, modelId: String) -> String:
|
||||
if not (modelVariant is Dictionary):
|
||||
return modelId
|
||||
var model: Dictionary = modelVariant
|
||||
var label := str(model.get("label", modelId)).strip_edges()
|
||||
var ownedBy := str(model.get("owned_by", "")).strip_edges()
|
||||
if not ownedBy.is_empty():
|
||||
return "%s · %s" % [label, ownedBy]
|
||||
return label
|
||||
|
||||
func _on_close_pressed() -> void:
|
||||
recruitmentFrame.visible = false
|
||||
_isSubmitting = false
|
||||
_isFetchingModels = false
|
||||
submitButton.disabled = false
|
||||
fetchModelsButton.disabled = false
|
||||
tokenInput.clear()
|
||||
|
||||
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))
|
||||
|
||||
func _get_cafe_companion_manager() -> Node:
|
||||
return get_node_or_null("/root/CafeCompanionManager")
|
||||
|
||||
func _format_service_point_label(servicePointId: String) -> String:
|
||||
if servicePointId.begins_with("ServiceIdlePoint"):
|
||||
var pointNumber := _parse_point_number(servicePointId)
|
||||
if pointNumber > 0:
|
||||
return "%s号陪伴位" % _format_chinese_number(pointNumber)
|
||||
return servicePointId
|
||||
|
||||
func _parse_point_number(value: String) -> int:
|
||||
var digits := ""
|
||||
for index in range(value.length()):
|
||||
var character := value.substr(index, 1)
|
||||
if character >= "0" and character <= "9":
|
||||
digits += character
|
||||
return int(digits) if not digits.is_empty() else 0
|
||||
|
||||
func _format_chinese_number(value: int) -> String:
|
||||
const CHINESE_DIGITS: Array[String] = [
|
||||
"零", "一", "二", "三", "四", "五", "六", "七", "八", "九"
|
||||
]
|
||||
if value <= 0:
|
||||
return str(value)
|
||||
if value < 10:
|
||||
return CHINESE_DIGITS[value]
|
||||
if value == 10:
|
||||
return "十"
|
||||
if value < 20:
|
||||
return "十%s" % CHINESE_DIGITS[value - 10]
|
||||
if value < 100:
|
||||
var tens := value / 10
|
||||
var ones := value % 10
|
||||
return "%s十%s" % [CHINESE_DIGITS[tens], CHINESE_DIGITS[ones]] if ones > 0 else "%s十" % CHINESE_DIGITS[tens]
|
||||
return str(value)
|
||||
1
scenes/ui/CafeCompanionRecruitmentPanel.gd.uid
Normal file
1
scenes/ui/CafeCompanionRecruitmentPanel.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b58ngm7oese0r
|
||||
357
scenes/ui/CafeCompanionRecruitmentPanel.tscn
Normal file
357
scenes/ui/CafeCompanionRecruitmentPanel.tscn
Normal file
@@ -0,0 +1,357 @@
|
||||
[gd_scene load_steps=9 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/ui/CafeCompanionRecruitmentPanel.gd" id="1_script"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="Style_Panel"]
|
||||
bg_color = Color(0.96, 0.975, 0.982, 0.98)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.46, 0.62, 0.70, 0.28)
|
||||
corner_radius_top_left = 12
|
||||
corner_radius_top_right = 12
|
||||
corner_radius_bottom_right = 12
|
||||
corner_radius_bottom_left = 12
|
||||
shadow_color = Color(0.06, 0.09, 0.12, 0.22)
|
||||
shadow_size = 12
|
||||
shadow_offset = Vector2(0, 5)
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="Style_Input"]
|
||||
bg_color = Color(1, 1, 1, 0.96)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.55, 0.68, 0.76, 0.36)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
content_margin_left = 12
|
||||
content_margin_top = 6
|
||||
content_margin_right = 12
|
||||
content_margin_bottom = 6
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="Style_InputLine"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="Style_ButtonNormal"]
|
||||
bg_color = Color(0.22, 0.56, 0.78, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="Style_ButtonHover"]
|
||||
bg_color = Color(0.29, 0.64, 0.84, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="Style_ButtonPressed"]
|
||||
bg_color = Color(0.16, 0.45, 0.66, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="Style_Close"]
|
||||
bg_color = Color(0.86, 0.91, 0.94, 0.85)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="Style_Empty"]
|
||||
|
||||
[node name="CafeCompanionRecruitmentPanel" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_script")
|
||||
|
||||
[node name="CafeRecruitmentFrame" type="PanelContainer" parent="."]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
anchor_left = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -320.0
|
||||
offset_top = -360.0
|
||||
offset_right = 320.0
|
||||
offset_bottom = 360.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 0
|
||||
theme_override_styles/panel = SubResource("Style_Panel")
|
||||
|
||||
[node name="PanelMargin" type="MarginContainer" parent="CafeRecruitmentFrame"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 20
|
||||
theme_override_constants/margin_top = 14
|
||||
theme_override_constants/margin_right = 20
|
||||
theme_override_constants/margin_bottom = 14
|
||||
|
||||
[node name="ContentVBox" type="VBoxContainer" parent="CafeRecruitmentFrame/PanelMargin"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 6
|
||||
|
||||
[node name="HeaderRow" type="HBoxContainer" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="TitleStack" type="VBoxContainer" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox/HeaderRow"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 2
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox/HeaderRow/TitleStack"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.13, 0.21, 0.27, 1)
|
||||
theme_override_font_sizes/font_size = 20
|
||||
text = "咖啡店陪伴招聘"
|
||||
text_overrun_behavior = 3
|
||||
|
||||
[node name="SubtitleLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox/HeaderRow/TitleStack"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.40, 0.50, 0.57, 1)
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "鲸鱼咖啡馆 · 吧台招聘终端"
|
||||
text_overrun_behavior = 3
|
||||
|
||||
[node name="CloseButton" type="Button" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox/HeaderRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(34, 34)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
mouse_default_cursor_shape = 2
|
||||
theme_override_colors/font_color = Color(0.20, 0.30, 0.36, 1)
|
||||
theme_override_font_sizes/font_size = 18
|
||||
theme_override_styles/normal = SubResource("Style_Close")
|
||||
theme_override_styles/hover = SubResource("Style_Close")
|
||||
theme_override_styles/pressed = SubResource("Style_Close")
|
||||
theme_override_styles/focus = SubResource("Style_Empty")
|
||||
text = "x"
|
||||
|
||||
[node name="ServicePointLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.18, 0.26, 0.32, 1)
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "陪伴位"
|
||||
|
||||
[node name="ServicePointOption" type="OptionButton" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 34)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
mouse_default_cursor_shape = 2
|
||||
theme_override_font_sizes/font_size = 15
|
||||
|
||||
[node name="EmploymentDurationLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.18, 0.26, 0.32, 1)
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "打工时间"
|
||||
|
||||
[node name="EmploymentDurationOption" type="OptionButton" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 34)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
mouse_default_cursor_shape = 2
|
||||
theme_override_font_sizes/font_size = 15
|
||||
|
||||
[node name="PersonaNameLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.18, 0.26, 0.32, 1)
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "人设名称"
|
||||
|
||||
[node name="PersonaNameInput" type="LineEdit" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 34)
|
||||
layout_mode = 2
|
||||
placeholder_text = "例如:蓝莓摩卡"
|
||||
caret_blink = true
|
||||
theme_override_colors/font_color = Color(0.16, 0.21, 0.27, 1)
|
||||
theme_override_colors/font_placeholder_color = Color(0.48, 0.56, 0.62, 0.78)
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_styles/normal = SubResource("Style_Input")
|
||||
theme_override_styles/focus = SubResource("Style_Input")
|
||||
|
||||
[node name="ProtocolLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.18, 0.26, 0.32, 1)
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "协议"
|
||||
|
||||
[node name="ProtocolOption" type="OptionButton" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 34)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
mouse_default_cursor_shape = 2
|
||||
theme_override_font_sizes/font_size = 15
|
||||
|
||||
[node name="BaseUrlLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.18, 0.26, 0.32, 1)
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "OpenAI-compatible URL"
|
||||
|
||||
[node name="BaseUrlInput" type="LineEdit" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 34)
|
||||
layout_mode = 2
|
||||
placeholder_text = "https://api.example.com/v1"
|
||||
caret_blink = true
|
||||
theme_override_colors/font_color = Color(0.16, 0.21, 0.27, 1)
|
||||
theme_override_colors/font_placeholder_color = Color(0.48, 0.56, 0.62, 0.78)
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_styles/normal = SubResource("Style_Input")
|
||||
theme_override_styles/focus = SubResource("Style_Input")
|
||||
|
||||
[node name="TokenLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.18, 0.26, 0.32, 1)
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "Token"
|
||||
|
||||
[node name="TokenInput" type="LineEdit" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 34)
|
||||
layout_mode = 2
|
||||
placeholder_text = "只提交给后端保存"
|
||||
secret = true
|
||||
caret_blink = true
|
||||
theme_override_colors/font_color = Color(0.16, 0.21, 0.27, 1)
|
||||
theme_override_colors/font_placeholder_color = Color(0.48, 0.56, 0.62, 0.78)
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_styles/normal = SubResource("Style_Input")
|
||||
theme_override_styles/focus = SubResource("Style_Input")
|
||||
|
||||
[node name="ModelLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.18, 0.26, 0.32, 1)
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "模型"
|
||||
|
||||
[node name="ModelRow" type="HBoxContainer" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="ModelInput" type="LineEdit" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox/ModelRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 34)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "gpt-4o-mini"
|
||||
placeholder_text = "gpt-4o-mini"
|
||||
caret_blink = true
|
||||
theme_override_colors/font_color = Color(0.16, 0.21, 0.27, 1)
|
||||
theme_override_colors/font_placeholder_color = Color(0.48, 0.56, 0.62, 0.78)
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_styles/normal = SubResource("Style_Input")
|
||||
theme_override_styles/focus = SubResource("Style_Input")
|
||||
|
||||
[node name="FetchModelsButton" type="Button" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox/ModelRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(104, 34)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
mouse_default_cursor_shape = 2
|
||||
theme_override_colors/font_color = Color(1, 1, 1, 1)
|
||||
theme_override_font_sizes/font_size = 14
|
||||
theme_override_styles/normal = SubResource("Style_ButtonNormal")
|
||||
theme_override_styles/hover = SubResource("Style_ButtonHover")
|
||||
theme_override_styles/pressed = SubResource("Style_ButtonPressed")
|
||||
theme_override_styles/focus = SubResource("Style_Empty")
|
||||
text = "获取模型"
|
||||
|
||||
[node name="ModelOption" type="OptionButton" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
custom_minimum_size = Vector2(0, 34)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
mouse_default_cursor_shape = 2
|
||||
theme_override_font_sizes/font_size = 14
|
||||
|
||||
[node name="PersonaPromptLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.18, 0.26, 0.32, 1)
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "人设指令"
|
||||
|
||||
[node name="PersonaPromptInput" type="TextEdit" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 76)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
placeholder_text = "写清楚角色性格、语气、边界和陪伴方式"
|
||||
wrap_mode = 1
|
||||
caret_blink = true
|
||||
theme_override_colors/font_color = Color(0.16, 0.21, 0.27, 1)
|
||||
theme_override_colors/font_placeholder_color = Color(0.48, 0.56, 0.62, 0.78)
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_styles/normal = SubResource("Style_Input")
|
||||
theme_override_styles/focus = SubResource("Style_Input")
|
||||
|
||||
[node name="WelcomeMessageLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.18, 0.26, 0.32, 1)
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = "欢迎语"
|
||||
|
||||
[node name="WelcomeMessageInput" type="LineEdit" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 34)
|
||||
layout_mode = 2
|
||||
placeholder_text = "可选"
|
||||
caret_blink = true
|
||||
theme_override_colors/font_color = Color(0.16, 0.21, 0.27, 1)
|
||||
theme_override_colors/font_placeholder_color = Color(0.48, 0.56, 0.62, 0.78)
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_styles/normal = SubResource("Style_Input")
|
||||
theme_override_styles/focus = SubResource("Style_Input")
|
||||
|
||||
[node name="EnabledCheckBox" type="CheckBox" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
button_pressed = true
|
||||
theme_override_colors/font_color = Color(0.18, 0.26, 0.32, 1)
|
||||
theme_override_font_sizes/font_size = 14
|
||||
text = "立即开始接待"
|
||||
|
||||
[node name="SubmitButton" type="Button" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 38)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
mouse_default_cursor_shape = 2
|
||||
theme_override_colors/font_color = Color(1, 1, 1, 1)
|
||||
theme_override_font_sizes/font_size = 16
|
||||
theme_override_styles/normal = SubResource("Style_ButtonNormal")
|
||||
theme_override_styles/hover = SubResource("Style_ButtonHover")
|
||||
theme_override_styles/pressed = SubResource("Style_ButtonPressed")
|
||||
theme_override_styles/focus = SubResource("Style_Empty")
|
||||
text = "提交雇佣登记"
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="CafeRecruitmentFrame/PanelMargin/ContentVBox"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 18)
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.32, 0.42, 0.48, 1)
|
||||
theme_override_font_sizes/font_size = 13
|
||||
text = ""
|
||||
text_overrun_behavior = 3
|
||||
130
scenes/ui/ChatBubble.gd
Normal file
130
scenes/ui/ChatBubble.gd
Normal file
@@ -0,0 +1,130 @@
|
||||
extends Control
|
||||
|
||||
const DEFAULT_DURATION: float = 5.0
|
||||
const TARGET_OFFSET: Vector2 = Vector2(0, -84)
|
||||
const TARGET_TOP_GAP: float = 10.0
|
||||
const FALLBACK_TARGET_HEIGHT: float = 64.0
|
||||
const MIN_TEXT_WIDTH: float = 96.0
|
||||
const MAX_TEXT_WIDTH: float = 230.0
|
||||
const TEXT_FONT_SIZE: float = 18.0
|
||||
const LINE_HEIGHT: float = 24.0
|
||||
|
||||
@onready var label: Label = $PanelContainer/Label
|
||||
@onready var panel: PanelContainer = $PanelContainer
|
||||
|
||||
var _targetNode: Node2D
|
||||
var _targetOffset: Vector2 = TARGET_OFFSET
|
||||
var _originalText: String = ""
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _targetNode == null or not is_instance_valid(_targetNode):
|
||||
return
|
||||
|
||||
_update_position()
|
||||
|
||||
func set_text(text: String, targetNode: Node2D = null, targetOffset: Vector2 = TARGET_OFFSET) -> void:
|
||||
_originalText = text
|
||||
_targetNode = targetNode
|
||||
_targetOffset = targetOffset
|
||||
_update_size()
|
||||
|
||||
if _targetNode != null:
|
||||
_update_position()
|
||||
|
||||
await get_tree().create_timer(DEFAULT_DURATION).timeout
|
||||
queue_free()
|
||||
|
||||
func _update_size() -> void:
|
||||
if panel == null:
|
||||
return
|
||||
var metrics := _get_text_metrics(_originalText)
|
||||
label.text = str(metrics.get("display_text", _originalText))
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_OFF
|
||||
label.custom_minimum_size = Vector2(float(metrics.get("width", MIN_TEXT_WIDTH)), float(metrics.get("height", LINE_HEIGHT)))
|
||||
label.size = label.custom_minimum_size
|
||||
panel.reset_size()
|
||||
reset_size()
|
||||
panel.size = panel.get_combined_minimum_size()
|
||||
size = panel.size
|
||||
|
||||
func _update_position() -> void:
|
||||
var anchor := _get_target_top_screen_position()
|
||||
var bubbleSize := _get_bubble_size()
|
||||
global_position = Vector2(
|
||||
anchor.x - bubbleSize.x * 0.5 + _targetOffset.x,
|
||||
anchor.y - bubbleSize.y - TARGET_TOP_GAP
|
||||
).round()
|
||||
|
||||
func _get_bubble_size() -> Vector2:
|
||||
if panel != null:
|
||||
var combined := panel.get_combined_minimum_size()
|
||||
if combined.x > 0.0 and combined.y > 0.0:
|
||||
return combined
|
||||
if size.x > 0.0 and size.y > 0.0:
|
||||
return size
|
||||
return Vector2(280.0, 52.0)
|
||||
|
||||
func _get_text_metrics(text: String) -> Dictionary:
|
||||
var lines: Array[String] = []
|
||||
var currentLine := ""
|
||||
var currentWidth := 0.0
|
||||
var maxLineWidth := 0.0
|
||||
for index in range(text.length()):
|
||||
var character := text.substr(index, 1)
|
||||
var characterWidth := _measure_text_width(character)
|
||||
if not currentLine.is_empty() and currentWidth + characterWidth > MAX_TEXT_WIDTH:
|
||||
lines.append(currentLine)
|
||||
maxLineWidth = max(maxLineWidth, currentWidth)
|
||||
currentLine = character
|
||||
currentWidth = characterWidth
|
||||
else:
|
||||
currentLine += character
|
||||
currentWidth += characterWidth
|
||||
|
||||
if not currentLine.is_empty() or lines.is_empty():
|
||||
lines.append(currentLine)
|
||||
maxLineWidth = max(maxLineWidth, currentWidth)
|
||||
|
||||
return {
|
||||
"display_text": "\n".join(lines),
|
||||
"width": clamp(maxLineWidth + 4.0, MIN_TEXT_WIDTH, MAX_TEXT_WIDTH),
|
||||
"height": LINE_HEIGHT * float(lines.size())
|
||||
}
|
||||
|
||||
func _measure_text_width(text: String) -> float:
|
||||
if label != null:
|
||||
var font := label.get_theme_font("font")
|
||||
if font != null:
|
||||
var fontSize := label.get_theme_font_size("font_size")
|
||||
if fontSize <= 0:
|
||||
fontSize = int(TEXT_FONT_SIZE)
|
||||
var measured := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, fontSize).x
|
||||
if measured > 0.0:
|
||||
return measured
|
||||
|
||||
var estimated := 0.0
|
||||
for index in range(text.length()):
|
||||
var code := text.unicode_at(index)
|
||||
estimated += TEXT_FONT_SIZE * 0.56 if code <= 0x7f else TEXT_FONT_SIZE
|
||||
return estimated
|
||||
|
||||
func _get_target_top_screen_position() -> Vector2:
|
||||
var targetScreenPosition: Vector2 = _targetNode.get_global_transform_with_canvas().origin
|
||||
var visualTopOffset := _get_target_visual_top_offset()
|
||||
return targetScreenPosition + visualTopOffset
|
||||
|
||||
func _get_target_visual_top_offset() -> Vector2:
|
||||
var topOffset := Vector2(0.0, -FALLBACK_TARGET_HEIGHT)
|
||||
if _targetNode == null:
|
||||
return topOffset
|
||||
|
||||
var sprite := _targetNode.get_node_or_null("Sprite2D") as Sprite2D
|
||||
if sprite == null:
|
||||
return topOffset
|
||||
|
||||
var rect := sprite.get_rect()
|
||||
var localTop := sprite.position + Vector2(0.0, rect.position.y * sprite.scale.y)
|
||||
var nodeToCanvas := _targetNode.get_global_transform_with_canvas()
|
||||
var topScreenPosition := nodeToCanvas * localTop
|
||||
var targetScreenPosition: Vector2 = nodeToCanvas.origin
|
||||
return topScreenPosition - targetScreenPosition
|
||||
1
scenes/ui/ChatBubble.gd.uid
Normal file
1
scenes/ui/ChatBubble.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d04vhx8vvh2h6
|
||||
41
scenes/ui/ChatBubble.tscn
Normal file
41
scenes/ui/ChatBubble.tscn
Normal file
@@ -0,0 +1,41 @@
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/ui/ChatBubble.gd" id="1_script"]
|
||||
[ext_resource type="Theme" path="res://assets/ui/world_text_theme.tres" id="2_theme"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bubble_modern"]
|
||||
bg_color = Color(1, 1, 1, 0.94)
|
||||
corner_radius_top_left = 6
|
||||
corner_radius_top_right = 6
|
||||
corner_radius_bottom_right = 6
|
||||
corner_radius_bottom_left = 6
|
||||
content_margin_left = 14.0
|
||||
content_margin_top = 9.0
|
||||
content_margin_right = 14.0
|
||||
content_margin_bottom = 9.0
|
||||
shadow_color = Color(0, 0, 0, 0.2)
|
||||
shadow_size = 2
|
||||
|
||||
[node name="ChatBubble" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 0
|
||||
script = ExtResource("1_script")
|
||||
theme = ExtResource("2_theme")
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="PanelContainer" type="PanelContainer" parent="."]
|
||||
layout_mode = 0
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_bubble_modern")
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="Label" type="Label" parent="PanelContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.1, 0.1, 0.1, 1)
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "..."
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
autowrap_mode = 3
|
||||
mouse_filter = 2
|
||||
1071
scenes/ui/ChatUI.gd
Normal file
1071
scenes/ui/ChatUI.gd
Normal file
File diff suppressed because it is too large
Load Diff
1
scenes/ui/ChatUI.gd.uid
Normal file
1
scenes/ui/ChatUI.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c57ehdqxtlrrx
|
||||
350
scenes/ui/ChatUI.tscn
Normal file
350
scenes/ui/ChatUI.tscn
Normal file
@@ -0,0 +1,350 @@
|
||||
[gd_scene load_steps=15 format=3 uid="uid://bv7k2nan4xj8q"]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/ui/ChatUI.gd" id="1"]
|
||||
[ext_resource type="Script" path="res://scenes/ui/BubbleSendButton.gd" id="2"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_panel"]
|
||||
bg_color = Color(1, 0.996, 0.984, 0.97)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.78, 0.86, 0.93, 0.42)
|
||||
corner_radius_top_left = 26
|
||||
corner_radius_top_right = 26
|
||||
corner_radius_bottom_right = 26
|
||||
corner_radius_bottom_left = 26
|
||||
shadow_color = Color(0.12549, 0.282353, 0.407843, 0.18)
|
||||
shadow_size = 18
|
||||
shadow_offset = Vector2(0, 7)
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_tab"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_input"]
|
||||
bg_color = Color(1, 1, 1, 0.9)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.76, 0.85, 0.93, 0.9)
|
||||
corner_radius_top_left = 18
|
||||
corner_radius_top_right = 18
|
||||
corner_radius_bottom_right = 18
|
||||
corner_radius_bottom_left = 18
|
||||
content_margin_left = 16
|
||||
content_margin_top = 6
|
||||
content_margin_right = 16
|
||||
content_margin_bottom = 6
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_input_line"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_friend_button"]
|
||||
bg_color = Color(0.918, 0.961, 0.992, 0.96)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.73, 0.84, 0.93, 0.72)
|
||||
corner_radius_top_left = 14
|
||||
corner_radius_top_right = 14
|
||||
corner_radius_bottom_right = 14
|
||||
corner_radius_bottom_left = 14
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bubble_normal"]
|
||||
bg_color = Color(1, 1, 1, 0.94)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.72, 0.84, 0.93, 0.86)
|
||||
corner_radius_top_left = 16
|
||||
corner_radius_top_right = 16
|
||||
corner_radius_bottom_right = 16
|
||||
corner_radius_bottom_left = 16
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bubble_hover"]
|
||||
bg_color = Color(0.918, 0.961, 0.992, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.392, 0.718, 0.949, 0.8)
|
||||
corner_radius_top_left = 16
|
||||
corner_radius_top_right = 16
|
||||
corner_radius_bottom_right = 16
|
||||
corner_radius_bottom_left = 16
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bubble_pressed"]
|
||||
bg_color = Color(0.858, 0.925, 0.98, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.31, 0.64, 0.89, 0.86)
|
||||
corner_radius_top_left = 16
|
||||
corner_radius_top_right = 16
|
||||
corner_radius_bottom_right = 16
|
||||
corner_radius_bottom_left = 16
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_send_normal"]
|
||||
bg_color = Color(0.258824, 0.627451, 0.913725, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.56, 0.8, 0.98, 0.72)
|
||||
corner_radius_top_left = 16
|
||||
corner_radius_top_right = 16
|
||||
corner_radius_bottom_right = 16
|
||||
corner_radius_bottom_left = 16
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_send_hover"]
|
||||
bg_color = Color(0.345, 0.69, 0.945, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.68, 0.87, 1, 0.9)
|
||||
corner_radius_top_left = 16
|
||||
corner_radius_top_right = 16
|
||||
corner_radius_bottom_right = 16
|
||||
corner_radius_bottom_left = 16
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_send_pressed"]
|
||||
bg_color = Color(0.196, 0.52, 0.82, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.46, 0.72, 0.94, 0.9)
|
||||
corner_radius_top_left = 16
|
||||
corner_radius_top_right = 16
|
||||
corner_radius_bottom_right = 16
|
||||
corner_radius_bottom_left = 16
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_send_focus"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_friend_header"]
|
||||
bg_color = Color(0.925, 0.965, 1, 0.96)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.77, 0.87, 0.95, 0.7)
|
||||
corner_radius_top_left = 16
|
||||
corner_radius_top_right = 16
|
||||
corner_radius_bottom_right = 16
|
||||
corner_radius_bottom_left = 16
|
||||
|
||||
[node name="ChatUI" type="Control"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="ChatPanel" type="PanelContainer" parent="."]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
anchor_left = 0.024
|
||||
anchor_top = 1.0
|
||||
anchor_right = 0.024
|
||||
anchor_bottom = 1.0
|
||||
offset_top = -448.0
|
||||
offset_right = 548.0
|
||||
offset_bottom = -32.0
|
||||
grow_vertical = 0
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_panel")
|
||||
|
||||
[node name="PanelMargin" type="MarginContainer" parent="ChatPanel"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 20
|
||||
theme_override_constants/margin_top = 18
|
||||
theme_override_constants/margin_right = 20
|
||||
theme_override_constants/margin_bottom = 18
|
||||
|
||||
[node name="ContentVBox" type="VBoxContainer" parent="ChatPanel/PanelMargin"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="Tabs" type="HBoxContainer" parent="ChatPanel/PanelMargin/ContentVBox"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="PopularTab" type="VBoxContainer" parent="ChatPanel/PanelMargin/ContentVBox/Tabs"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="PopularTabButton" type="Button" parent="ChatPanel/PanelMargin/ContentVBox/Tabs/PopularTab"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 38)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
mouse_default_cursor_shape = 2
|
||||
theme_override_colors/font_color = Color(0.258824, 0.627451, 0.913725, 1)
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_styles/normal = SubResource("StyleBoxEmpty_tab")
|
||||
theme_override_styles/hover = SubResource("StyleBoxEmpty_tab")
|
||||
theme_override_styles/pressed = SubResource("StyleBoxEmpty_tab")
|
||||
theme_override_styles/focus = SubResource("StyleBoxEmpty_tab")
|
||||
text = "世界频道"
|
||||
|
||||
[node name="Underline" type="ColorRect" parent="ChatPanel/PanelMargin/ContentVBox/Tabs/PopularTab"]
|
||||
custom_minimum_size = Vector2(0, 2)
|
||||
layout_mode = 2
|
||||
color = Color(0.258824, 0.627451, 0.913725, 1)
|
||||
|
||||
[node name="RecentTab" type="VBoxContainer" parent="ChatPanel/PanelMargin/ContentVBox/Tabs"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="RecentTabButton" type="Button" parent="ChatPanel/PanelMargin/ContentVBox/Tabs/RecentTab"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 38)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
mouse_default_cursor_shape = 2
|
||||
theme_override_colors/font_color = Color(0.560784, 0.639216, 0.733333, 1)
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_styles/normal = SubResource("StyleBoxEmpty_tab")
|
||||
theme_override_styles/hover = SubResource("StyleBoxEmpty_tab")
|
||||
theme_override_styles/pressed = SubResource("StyleBoxEmpty_tab")
|
||||
theme_override_styles/focus = SubResource("StyleBoxEmpty_tab")
|
||||
text = "悄悄话"
|
||||
|
||||
[node name="Underline" type="ColorRect" parent="ChatPanel/PanelMargin/ContentVBox/Tabs/RecentTab"]
|
||||
custom_minimum_size = Vector2(0, 2)
|
||||
layout_mode = 2
|
||||
color = Color(1, 1, 1, 0)
|
||||
|
||||
[node name="FriendsTab" type="VBoxContainer" parent="ChatPanel/PanelMargin/ContentVBox/Tabs"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 4
|
||||
|
||||
[node name="FriendsTabButton" type="Button" parent="ChatPanel/PanelMargin/ContentVBox/Tabs/FriendsTab"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 38)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
mouse_default_cursor_shape = 2
|
||||
theme_override_colors/font_color = Color(0.560784, 0.639216, 0.733333, 1)
|
||||
theme_override_font_sizes/font_size = 15
|
||||
theme_override_styles/normal = SubResource("StyleBoxEmpty_tab")
|
||||
theme_override_styles/hover = SubResource("StyleBoxEmpty_tab")
|
||||
theme_override_styles/pressed = SubResource("StyleBoxEmpty_tab")
|
||||
theme_override_styles/focus = SubResource("StyleBoxEmpty_tab")
|
||||
text = "好友"
|
||||
|
||||
[node name="Underline" type="ColorRect" parent="ChatPanel/PanelMargin/ContentVBox/Tabs/FriendsTab"]
|
||||
custom_minimum_size = Vector2(0, 2)
|
||||
layout_mode = 2
|
||||
color = Color(1, 1, 1, 0)
|
||||
|
||||
[node name="ChatHistory" type="ScrollContainer" parent="ChatPanel/PanelMargin/ContentVBox"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="MessageList" type="VBoxContainer" parent="ChatPanel/PanelMargin/ContentVBox/ChatHistory"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 1
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="FriendsListSurface" type="PanelContainer" parent="ChatPanel/PanelMargin/ContentVBox"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
custom_minimum_size = Vector2(0, 50)
|
||||
layout_mode = 2
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_friend_header")
|
||||
|
||||
[node name="FriendsList" type="HBoxContainer" parent="ChatPanel/PanelMargin/ContentVBox/FriendsListSurface"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="AddFriendRow" type="HBoxContainer" parent="ChatPanel/PanelMargin/ContentVBox"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
custom_minimum_size = Vector2(0, 36)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
alignment = 2
|
||||
|
||||
[node name="AddFriendButton" type="Button" parent="ChatPanel/PanelMargin/ContentVBox/AddFriendRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(34, 34)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
tooltip_text = "添加好友"
|
||||
theme_override_colors/font_color = Color(0.258824, 0.627451, 0.913725, 1)
|
||||
theme_override_font_sizes/font_size = 17
|
||||
theme_override_styles/normal = SubResource("StyleBoxFlat_friend_button")
|
||||
theme_override_styles/hover = SubResource("StyleBoxFlat_friend_button")
|
||||
theme_override_styles/pressed = SubResource("StyleBoxFlat_friend_button")
|
||||
theme_override_styles/focus = SubResource("StyleBoxEmpty_send_focus")
|
||||
text = "+"
|
||||
|
||||
[node name="InputRow" type="HBoxContainer" parent="ChatPanel/PanelMargin/ContentVBox"]
|
||||
custom_minimum_size = Vector2(0, 50)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
alignment = 1
|
||||
|
||||
[node name="InputShell" type="PanelContainer" parent="ChatPanel/PanelMargin/ContentVBox/InputRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
mouse_filter = 1
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_input")
|
||||
|
||||
[node name="ChatInput" type="LineEdit" parent="ChatPanel/PanelMargin/ContentVBox/InputRow/InputShell"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
focus_mode = 2
|
||||
placeholder_text = "输入消息..."
|
||||
localize_numeral_system = false
|
||||
theme_override_colors/font_color = Color(0.188, 0.294, 0.424, 1)
|
||||
theme_override_colors/font_placeholder_color = Color(0.560784, 0.639216, 0.733333, 0.88)
|
||||
theme_override_font_sizes/font_size = 16
|
||||
theme_override_styles/normal = SubResource("StyleBoxEmpty_input_line")
|
||||
theme_override_styles/read_only = SubResource("StyleBoxEmpty_input_line")
|
||||
theme_override_styles/focus = SubResource("StyleBoxEmpty_input_line")
|
||||
|
||||
[node name="BubbleSendButton" type="Button" parent="ChatPanel/PanelMargin/ContentVBox/InputRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(48, 48)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
tooltip_text = "发送并弹出气泡"
|
||||
theme_override_colors/font_color = Color(0.207843, 0.427451, 0.686275, 1)
|
||||
theme_override_font_sizes/font_size = 18
|
||||
theme_override_styles/normal = SubResource("StyleBoxFlat_bubble_normal")
|
||||
theme_override_styles/hover = SubResource("StyleBoxFlat_bubble_hover")
|
||||
theme_override_styles/pressed = SubResource("StyleBoxFlat_bubble_pressed")
|
||||
theme_override_styles/focus = SubResource("StyleBoxEmpty_send_focus")
|
||||
script = ExtResource("2")
|
||||
|
||||
[node name="SendButton" type="Button" parent="ChatPanel/PanelMargin/ContentVBox/InputRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(48, 48)
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
theme_override_colors/font_color = Color(1, 1, 1, 1)
|
||||
theme_override_font_sizes/font_size = 22
|
||||
theme_override_styles/normal = SubResource("StyleBoxFlat_send_normal")
|
||||
theme_override_styles/hover = SubResource("StyleBoxFlat_send_hover")
|
||||
theme_override_styles/pressed = SubResource("StyleBoxFlat_send_pressed")
|
||||
theme_override_styles/focus = SubResource("StyleBoxEmpty_send_focus")
|
||||
text = "➤"
|
||||
516
scenes/ui/CourseBoardPanel.gd
Normal file
516
scenes/ui/CourseBoardPanel.gd
Normal file
@@ -0,0 +1,516 @@
|
||||
class_name CourseBoardPanel
|
||||
extends Control
|
||||
|
||||
# ============================================================================
|
||||
# CourseBoardPanel.gd - Datawhale 课程看板
|
||||
# ============================================================================
|
||||
# 展示 Datawhale 学习中心课程列表,由打工区 VirtualWhaleRecruitmentBoard 打开。
|
||||
# ============================================================================
|
||||
|
||||
const MOVEMENT_ACTIONS: Array[String] = ["move_left", "move_right", "move_up", "move_down"]
|
||||
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
|
||||
const API_PATH: String = "/course-resources/datawhale"
|
||||
const PANEL_SIZE: Vector2 = Vector2(1500, 1020)
|
||||
const MIN_MARGIN: Vector2 = Vector2(24, 24)
|
||||
const CARD_SIZE: Vector2 = Vector2(320, 348)
|
||||
const COVER_HEIGHT: float = 171.0
|
||||
const TEXT_COLOR: Color = Color(0.098, 0.140, 0.190)
|
||||
const MUTED_COLOR: Color = Color(0.380, 0.450, 0.540)
|
||||
const ACCENT_COLOR: Color = Color(0.000, 0.322, 0.851)
|
||||
const SOFT_BLUE: Color = Color(0.925, 0.965, 1.0, 1.0)
|
||||
const IMAGE_PLACEHOLDER: Color = Color(1.0, 0.965, 0.870, 1.0)
|
||||
|
||||
var _overlay: ColorRect
|
||||
var _panel: Control
|
||||
var _grid: GridContainer
|
||||
var _statusLabel: Label
|
||||
var _request: HTTPRequest
|
||||
var _isOpen: bool = false
|
||||
var _transitionTween: Tween
|
||||
|
||||
func _ready() -> void:
|
||||
visible = false
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
set_process(false)
|
||||
_buildUi()
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_RESIZED and is_instance_valid(_panel):
|
||||
_positionPanel()
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if not _isOpen:
|
||||
return
|
||||
if event is InputEventKey:
|
||||
var keyEvent := event as InputEventKey
|
||||
if keyEvent.pressed and not keyEvent.echo and keyEvent.keycode == KEY_ESCAPE:
|
||||
hide_panel()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _isOpen:
|
||||
_releaseMovementActions()
|
||||
|
||||
func show_panel() -> void:
|
||||
if _isOpen:
|
||||
return
|
||||
_isOpen = true
|
||||
visible = true
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
set_process(true)
|
||||
_releaseMovementInputState()
|
||||
_fetchCourses()
|
||||
_animatePanel(true)
|
||||
|
||||
func hide_panel() -> void:
|
||||
if not _isOpen:
|
||||
return
|
||||
_isOpen = false
|
||||
_releaseMovementInputState()
|
||||
_animatePanel(false)
|
||||
|
||||
func is_panel_open() -> bool:
|
||||
return _isOpen
|
||||
|
||||
func _buildUi() -> void:
|
||||
_overlay = ColorRect.new()
|
||||
_overlay.name = "courseBoardDimOverlay"
|
||||
_overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_overlay.color = Color(0.020, 0.036, 0.060, 0.64)
|
||||
_overlay.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_overlay.gui_input.connect(_onOverlayGuiInput)
|
||||
add_child(_overlay)
|
||||
|
||||
_panel = Control.new()
|
||||
_panel.name = "courseBoardPanel"
|
||||
_panel.custom_minimum_size = PANEL_SIZE
|
||||
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
add_child(_panel)
|
||||
_positionPanel()
|
||||
|
||||
var frame := PanelContainer.new()
|
||||
frame.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
frame.add_theme_stylebox_override("panel", _createPanelStyle(Color(0.972, 0.988, 1.0, 1.0), 26, true))
|
||||
frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_panel.add_child(frame)
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
margin.add_theme_constant_override("margin_left", 34)
|
||||
margin.add_theme_constant_override("margin_top", 28)
|
||||
margin.add_theme_constant_override("margin_right", 34)
|
||||
margin.add_theme_constant_override("margin_bottom", 28)
|
||||
_panel.add_child(margin)
|
||||
|
||||
var root := VBoxContainer.new()
|
||||
root.add_theme_constant_override("separation", 18)
|
||||
margin.add_child(root)
|
||||
root.add_child(_buildHeader())
|
||||
root.add_child(_buildGridPanel())
|
||||
root.add_child(_buildFooter())
|
||||
|
||||
_request = HTTPRequest.new()
|
||||
_request.timeout = 18.0
|
||||
_request.request_completed.connect(_onRequestCompleted)
|
||||
add_child(_request)
|
||||
_updatePanelAlpha(0.0, 0.0)
|
||||
|
||||
func _buildHeader() -> Control:
|
||||
var header := PanelContainer.new()
|
||||
header.custom_minimum_size = Vector2(0, 106)
|
||||
header.add_theme_stylebox_override("panel", _createPanelStyle(Color(0.000, 0.322, 0.851, 0.96), 24, false))
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 28)
|
||||
margin.add_theme_constant_override("margin_top", 12)
|
||||
margin.add_theme_constant_override("margin_right", 20)
|
||||
margin.add_theme_constant_override("margin_bottom", 12)
|
||||
header.add_child(margin)
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
row.add_theme_constant_override("separation", 16)
|
||||
margin.add_child(row)
|
||||
|
||||
var titleStack := VBoxContainer.new()
|
||||
titleStack.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
titleStack.add_theme_constant_override("separation", 4)
|
||||
row.add_child(titleStack)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "Datawhale 课程看板"
|
||||
title.add_theme_font_size_override("font_size", 32)
|
||||
title.add_theme_color_override("font_color", Color.WHITE)
|
||||
titleStack.add_child(title)
|
||||
|
||||
var subtitle := Label.new()
|
||||
subtitle.text = "AI学习中心最新课程"
|
||||
subtitle.add_theme_font_size_override("font_size", 18)
|
||||
subtitle.add_theme_color_override("font_color", Color(0.835, 0.925, 1.0, 1.0))
|
||||
titleStack.add_child(subtitle)
|
||||
|
||||
var closeButton := Button.new()
|
||||
closeButton.text = "×"
|
||||
closeButton.custom_minimum_size = Vector2(54, 54)
|
||||
closeButton.focus_mode = Control.FOCUS_NONE
|
||||
closeButton.add_theme_font_size_override("font_size", 30)
|
||||
closeButton.add_theme_color_override("font_color", Color.WHITE)
|
||||
closeButton.add_theme_stylebox_override("normal", _createButtonStyle(Color(1.0, 1.0, 1.0, 0.12)))
|
||||
closeButton.add_theme_stylebox_override("hover", _createButtonStyle(Color(1.0, 1.0, 1.0, 0.22)))
|
||||
closeButton.add_theme_stylebox_override("pressed", _createButtonStyle(Color(1.0, 1.0, 1.0, 0.30)))
|
||||
closeButton.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
|
||||
closeButton.pressed.connect(hide_panel)
|
||||
row.add_child(closeButton)
|
||||
return header
|
||||
|
||||
func _buildGridPanel() -> Control:
|
||||
var holder := PanelContainer.new()
|
||||
holder.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
holder.add_theme_stylebox_override("panel", _createPanelStyle(Color(0.948, 0.978, 1.0, 0.92), 20, false))
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 18)
|
||||
margin.add_theme_constant_override("margin_top", 18)
|
||||
margin.add_theme_constant_override("margin_right", 18)
|
||||
margin.add_theme_constant_override("margin_bottom", 18)
|
||||
holder.add_child(margin)
|
||||
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
|
||||
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
margin.add_child(scroll)
|
||||
|
||||
_grid = GridContainer.new()
|
||||
_grid.columns = 4
|
||||
_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_grid.add_theme_constant_override("h_separation", 24)
|
||||
_grid.add_theme_constant_override("v_separation", 24)
|
||||
scroll.add_child(_grid)
|
||||
return holder
|
||||
|
||||
func _buildFooter() -> Control:
|
||||
var footer := HBoxContainer.new()
|
||||
footer.custom_minimum_size = Vector2(0, 34)
|
||||
footer.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
|
||||
_statusLabel = Label.new()
|
||||
_statusLabel.text = "靠近招募看板按 E 打开,点击课程卡片进入详情"
|
||||
_statusLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_statusLabel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_statusLabel.add_theme_font_size_override("font_size", 16)
|
||||
_statusLabel.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
footer.add_child(_statusLabel)
|
||||
return footer
|
||||
|
||||
func _fetchCourses() -> void:
|
||||
_statusLabel.text = "正在同步 Datawhale 课程..."
|
||||
_clearChildren(_grid)
|
||||
var err := _request.request("%s%s" % [NetworkConfig.get_api_base_url(), API_PATH])
|
||||
if err != OK:
|
||||
_statusLabel.text = "课程加载失败:%s" % error_string(err)
|
||||
|
||||
func _onRequestCompleted(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300:
|
||||
_statusLabel.text = "课程加载失败,请稍后再试"
|
||||
return
|
||||
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if not parsed is Dictionary:
|
||||
_statusLabel.text = "课程数据解析失败"
|
||||
return
|
||||
|
||||
var payload: Dictionary = parsed as Dictionary
|
||||
if not bool(payload.get("success", true)):
|
||||
_statusLabel.text = str(payload.get("message", "课程加载失败"))
|
||||
return
|
||||
|
||||
var data: Variant = payload.get("data", {})
|
||||
if not data is Dictionary:
|
||||
_statusLabel.text = "课程数据为空"
|
||||
return
|
||||
|
||||
var coursesVariant: Variant = (data as Dictionary).get("courses", [])
|
||||
if not coursesVariant is Array:
|
||||
_statusLabel.text = "课程数据为空"
|
||||
return
|
||||
|
||||
var courses: Array = coursesVariant as Array
|
||||
_renderCourses(courses)
|
||||
_statusLabel.text = "已同步 %d 门课程" % courses.size()
|
||||
|
||||
func _renderCourses(rows: Array) -> void:
|
||||
_clearChildren(_grid)
|
||||
for row in rows:
|
||||
if row is Dictionary:
|
||||
_grid.add_child(_buildCourseCard(row as Dictionary))
|
||||
|
||||
func _buildCourseCard(course: Dictionary) -> Button:
|
||||
var card := Button.new()
|
||||
card.custom_minimum_size = CARD_SIZE
|
||||
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
card.focus_mode = Control.FOCUS_NONE
|
||||
card.clip_contents = true
|
||||
card.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
card.add_theme_stylebox_override("normal", _createCardStyle(false))
|
||||
card.add_theme_stylebox_override("hover", _createCardStyle(true))
|
||||
card.add_theme_stylebox_override("pressed", _createCardStyle(true))
|
||||
card.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
|
||||
card.pressed.connect(_openCourse.bind(course))
|
||||
|
||||
var root := VBoxContainer.new()
|
||||
root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
root.add_theme_constant_override("separation", 0)
|
||||
root.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
card.add_child(root)
|
||||
|
||||
var coverPanel := Control.new()
|
||||
coverPanel.custom_minimum_size = Vector2(0, COVER_HEIGHT)
|
||||
coverPanel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
coverPanel.clip_contents = true
|
||||
coverPanel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
root.add_child(coverPanel)
|
||||
|
||||
var coverBg := ColorRect.new()
|
||||
coverBg.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
coverBg.color = IMAGE_PLACEHOLDER
|
||||
coverBg.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
coverPanel.add_child(coverBg)
|
||||
|
||||
var coverImage := TextureRect.new()
|
||||
coverImage.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
coverImage.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
coverImage.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
|
||||
coverImage.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
coverPanel.add_child(coverImage)
|
||||
|
||||
var coverText := Label.new()
|
||||
coverText.text = _shortTitle(str(course.get("title", "课程")))
|
||||
coverText.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
coverText.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
coverText.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
coverText.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
coverText.add_theme_font_size_override("font_size", 22)
|
||||
coverText.add_theme_color_override("font_color", ACCENT_COLOR)
|
||||
coverText.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
coverPanel.add_child(coverText)
|
||||
_loadCoverImage(str(course.get("coverUrl", "")), coverImage, coverText)
|
||||
|
||||
var bodyMargin := MarginContainer.new()
|
||||
bodyMargin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
bodyMargin.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
bodyMargin.add_theme_constant_override("margin_left", 16)
|
||||
bodyMargin.add_theme_constant_override("margin_top", 16)
|
||||
bodyMargin.add_theme_constant_override("margin_right", 16)
|
||||
bodyMargin.add_theme_constant_override("margin_bottom", 30)
|
||||
bodyMargin.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
root.add_child(bodyMargin)
|
||||
|
||||
var info := VBoxContainer.new()
|
||||
info.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
info.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
info.clip_contents = true
|
||||
info.add_theme_constant_override("separation", 6)
|
||||
info.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
bodyMargin.add_child(info)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = str(course.get("title", "未命名课程"))
|
||||
title.custom_minimum_size = Vector2(0, 46)
|
||||
title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
title.add_theme_font_size_override("font_size", 18)
|
||||
title.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
title.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
info.add_child(title)
|
||||
|
||||
var intro := Label.new()
|
||||
intro.text = str(course.get("intro", ""))
|
||||
intro.custom_minimum_size = Vector2(0, 58)
|
||||
intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
intro.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
intro.add_theme_font_size_override("font_size", 13)
|
||||
intro.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
intro.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
info.add_child(intro)
|
||||
|
||||
var spacer := Control.new()
|
||||
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
info.add_child(spacer)
|
||||
|
||||
var meta := HFlowContainer.new()
|
||||
meta.custom_minimum_size = Vector2(0, 34)
|
||||
meta.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
meta.add_theme_constant_override("h_separation", 6)
|
||||
meta.add_theme_constant_override("v_separation", 6)
|
||||
meta.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
info.add_child(meta)
|
||||
meta.add_child(_buildTag(str(course.get("difficultyLabel", "其他")), Color(0.918, 0.960, 1.0, 1.0), ACCENT_COLOR, 56.0))
|
||||
meta.add_child(_buildTag(str(course.get("categoryLabel", "其他")), Color(0.945, 0.950, 0.965, 1.0), MUTED_COLOR, 112.0))
|
||||
|
||||
var views := int(course.get("viewCount", 0))
|
||||
if views > 0:
|
||||
meta.add_child(_buildTag(_formatViews(views), Color(0.950, 0.980, 0.965, 1.0), Color(0.160, 0.510, 0.340), 82.0))
|
||||
|
||||
return card
|
||||
|
||||
func _buildTag(text: String, bgColor: Color, fontColor: Color, maxWidth: float) -> Label:
|
||||
var tag := Label.new()
|
||||
tag.text = text
|
||||
tag.custom_minimum_size = Vector2(maxWidth, 26)
|
||||
tag.clip_text = true
|
||||
tag.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
tag.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
tag.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
tag.add_theme_font_size_override("font_size", 12)
|
||||
tag.add_theme_color_override("font_color", fontColor)
|
||||
tag.add_theme_stylebox_override("normal", _createPanelStyle(bgColor, 12, false))
|
||||
tag.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
return tag
|
||||
|
||||
func _loadCoverImage(url: String, textureRect: TextureRect, fallbackLabel: Label) -> void:
|
||||
var coverUrl := url.strip_edges()
|
||||
if coverUrl.is_empty():
|
||||
return
|
||||
|
||||
var request := HTTPRequest.new()
|
||||
request.timeout = 12.0
|
||||
request.request_completed.connect(_onCoverRequestCompleted.bind(textureRect, fallbackLabel, request))
|
||||
add_child(request)
|
||||
var err := request.request(coverUrl)
|
||||
if err != OK:
|
||||
request.queue_free()
|
||||
|
||||
func _onCoverRequestCompleted(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray, textureRect: TextureRect, fallbackLabel: Label, request: HTTPRequest) -> void:
|
||||
if is_instance_valid(request):
|
||||
request.queue_free()
|
||||
if not is_instance_valid(textureRect):
|
||||
return
|
||||
if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300:
|
||||
return
|
||||
|
||||
var image := Image.new()
|
||||
var loadError := image.load_png_from_buffer(body)
|
||||
if loadError != OK:
|
||||
loadError = image.load_jpg_from_buffer(body)
|
||||
if loadError != OK:
|
||||
loadError = image.load_webp_from_buffer(body)
|
||||
if loadError != OK:
|
||||
return
|
||||
|
||||
textureRect.texture = ImageTexture.create_from_image(image)
|
||||
if is_instance_valid(fallbackLabel):
|
||||
fallbackLabel.visible = false
|
||||
|
||||
func _openCourse(course: Dictionary) -> void:
|
||||
var detailUrl := str(course.get("detailUrl", "")).strip_edges()
|
||||
if detailUrl.is_empty():
|
||||
return
|
||||
OS.shell_open(detailUrl)
|
||||
|
||||
func _onOverlayGuiInput(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton:
|
||||
var mouseEvent := event as InputEventMouseButton
|
||||
if mouseEvent.pressed and mouseEvent.button_index == MOUSE_BUTTON_LEFT:
|
||||
hide_panel()
|
||||
|
||||
func _positionPanel() -> void:
|
||||
var viewportSize := get_viewport_rect().size
|
||||
var width := minf(PANEL_SIZE.x, maxf(980.0, viewportSize.x - MIN_MARGIN.x * 2.0))
|
||||
var height := minf(PANEL_SIZE.y, maxf(760.0, viewportSize.y - MIN_MARGIN.y * 2.0))
|
||||
_panel.position = (viewportSize - Vector2(width, height)) * 0.5
|
||||
_panel.size = Vector2(width, height)
|
||||
_panel.pivot_offset = _panel.size * 0.5
|
||||
|
||||
func _animatePanel(opening: bool) -> void:
|
||||
if is_instance_valid(_transitionTween):
|
||||
_transitionTween.kill()
|
||||
_transitionTween = create_tween()
|
||||
_transitionTween.set_parallel(true)
|
||||
if opening:
|
||||
_updatePanelAlpha(0.0, 0.0)
|
||||
_panel.scale = Vector2(0.985, 0.985)
|
||||
_transitionTween.tween_property(_overlay, "modulate:a", 1.0, 0.16)
|
||||
_transitionTween.tween_property(_panel, "modulate:a", 1.0, 0.18)
|
||||
_transitionTween.tween_property(_panel, "scale", Vector2.ONE, 0.18)
|
||||
else:
|
||||
_transitionTween.tween_property(_overlay, "modulate:a", 0.0, 0.12)
|
||||
_transitionTween.tween_property(_panel, "modulate:a", 0.0, 0.12)
|
||||
_transitionTween.tween_property(_panel, "scale", Vector2(0.985, 0.985), 0.12)
|
||||
_transitionTween.finished.connect(_onCloseAnimationFinished)
|
||||
|
||||
func _onCloseAnimationFinished() -> void:
|
||||
if _isOpen:
|
||||
return
|
||||
visible = false
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
set_process(false)
|
||||
|
||||
func _updatePanelAlpha(overlayAlpha: float, panelAlpha: float) -> void:
|
||||
if is_instance_valid(_overlay):
|
||||
_overlay.modulate.a = overlayAlpha
|
||||
if is_instance_valid(_panel):
|
||||
_panel.modulate.a = panelAlpha
|
||||
|
||||
func _releaseMovementInputState() -> void:
|
||||
Input.flush_buffered_events()
|
||||
_releaseMovementActions()
|
||||
|
||||
func _releaseMovementActions() -> void:
|
||||
for action in MOVEMENT_ACTIONS:
|
||||
Input.action_release(action)
|
||||
|
||||
func _clearChildren(node: Node) -> void:
|
||||
for child in node.get_children():
|
||||
child.queue_free()
|
||||
|
||||
func _shortTitle(title: String) -> String:
|
||||
var trimmed := title.strip_edges()
|
||||
if trimmed.length() <= 8:
|
||||
return trimmed
|
||||
return trimmed.substr(0, 8)
|
||||
|
||||
func _formatViews(views: int) -> String:
|
||||
if views >= 10000:
|
||||
var wan := float(views) / 10000.0
|
||||
return "%.1f万看过" % wan
|
||||
return "%d人看过" % views
|
||||
|
||||
func _createPanelStyle(color: Color, radius: int, shadow: bool) -> 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.content_margin_left = 10
|
||||
style.content_margin_top = 7
|
||||
style.content_margin_right = 10
|
||||
style.content_margin_bottom = 7
|
||||
if shadow:
|
||||
style.shadow_color = Color(0.030, 0.120, 0.250, 0.18)
|
||||
style.shadow_size = 16
|
||||
style.shadow_offset = Vector2(0, 5)
|
||||
return style
|
||||
|
||||
func _createButtonStyle(color: Color) -> StyleBoxFlat:
|
||||
var style := _createPanelStyle(color, 18, false)
|
||||
style.content_margin_left = 0
|
||||
style.content_margin_top = 0
|
||||
style.content_margin_right = 0
|
||||
style.content_margin_bottom = 0
|
||||
return style
|
||||
|
||||
func _createCardStyle(hover: bool) -> StyleBoxFlat:
|
||||
var style := _createPanelStyle(Color.WHITE if not hover else Color(0.986, 0.996, 1.0, 1.0), 18, false)
|
||||
style.border_width_left = 2
|
||||
style.border_width_top = 2
|
||||
style.border_width_right = 2
|
||||
style.border_width_bottom = 2
|
||||
style.border_color = Color(0.815, 0.890, 0.960, 0.92) if not hover else Color(0.000, 0.322, 0.851, 0.72)
|
||||
style.shadow_color = Color(0.040, 0.150, 0.300, 0.08 if not hover else 0.14)
|
||||
style.shadow_size = 4 if not hover else 8
|
||||
style.shadow_offset = Vector2(0, 3)
|
||||
return style
|
||||
1
scenes/ui/CourseBoardPanel.gd.uid
Normal file
1
scenes/ui/CourseBoardPanel.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ba886s1nvwl55
|
||||
13
scenes/ui/CourseBoardPanel.tscn
Normal file
13
scenes/ui/CourseBoardPanel.tscn
Normal file
@@ -0,0 +1,13 @@
|
||||
[gd_scene load_steps=2 format=4 uid="uid://whaletown_course_board_panel"]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/ui/CourseBoardPanel.gd" id="1_course_board"]
|
||||
|
||||
[node name="CourseBoardPanel" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_course_board")
|
||||
1371
scenes/ui/DatawhaleHonorRankingPanel.gd
Normal file
1371
scenes/ui/DatawhaleHonorRankingPanel.gd
Normal file
File diff suppressed because it is too large
Load Diff
1
scenes/ui/DatawhaleHonorRankingPanel.gd.uid
Normal file
1
scenes/ui/DatawhaleHonorRankingPanel.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cgj33ppqlxwl5
|
||||
647
scenes/ui/FriendListPanel.gd
Normal file
647
scenes/ui/FriendListPanel.gd
Normal file
@@ -0,0 +1,647 @@
|
||||
extends Control
|
||||
|
||||
# ============================================================================
|
||||
# FriendListPanel.gd - 右下角好友列表面板
|
||||
# ============================================================================
|
||||
# 独立展示好友在线、好友请求和好友选择。点击好友后通过事件驱动 ChatUI
|
||||
# 切换到对应好友会话,不在面板内承载聊天内容。
|
||||
# ============================================================================
|
||||
|
||||
const PANEL_SIZE: Vector2 = Vector2(384, 492)
|
||||
const PANEL_MARGIN: Vector2 = Vector2(32, 32)
|
||||
const ONLINE_COLOR: Color = Color(0.286, 0.765, 0.486)
|
||||
const OFFLINE_COLOR: Color = Color(0.678, 0.725, 0.773)
|
||||
const TEXT_COLOR: Color = Color(0.188, 0.294, 0.424)
|
||||
const MUTED_COLOR: Color = Color(0.560, 0.639, 0.733)
|
||||
const ACCENT_COLOR: Color = Color(0.258824, 0.627451, 0.913725)
|
||||
const SURFACE_COLOR: Color = Color(1.0, 0.996, 0.984, 0.97)
|
||||
const SURFACE_RAISED_COLOR: Color = Color(1.0, 1.0, 1.0, 0.9)
|
||||
const BORDER_COLOR: Color = Color(0.788, 0.851, 0.910, 0.52)
|
||||
const FRIEND_AVATAR_SIZE: Vector2 = Vector2(48, 48)
|
||||
const FRIEND_AVATAR_CORNER_RADIUS: int = 12
|
||||
|
||||
@export var autoRequestFriendList: bool = true
|
||||
|
||||
var _panel: PanelContainer
|
||||
var _titleLabel: Label
|
||||
var _summaryLabel: Label
|
||||
var _closeButton: Button
|
||||
var _requestList: VBoxContainer
|
||||
var _friendList: VBoxContainer
|
||||
var _statusLabel: Label
|
||||
|
||||
var _friends: Array[Dictionary] = []
|
||||
var _requests: Array[Dictionary] = []
|
||||
var _supported: bool = true
|
||||
var _statusMessage: String = ""
|
||||
var _isOpen: bool = false
|
||||
var _hasRequestedFriendList: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_build_ui()
|
||||
set_panel_open(false)
|
||||
_subscribe_to_events()
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var eventSystem := _get_event_system()
|
||||
if eventSystem != null:
|
||||
eventSystem.call("disconnect_event", EventNames.CHAT_FRIENDS_UPDATED, _on_friends_updated, self)
|
||||
eventSystem.call("disconnect_event", EventNames.HUD_FRIEND_LIST_TOGGLE, _on_friend_list_toggle, self)
|
||||
eventSystem.call("disconnect_event", EventNames.SETTINGS_CHANGED, _on_settings_changed, self)
|
||||
|
||||
func set_panel_open(open: bool) -> void:
|
||||
_isOpen = open
|
||||
if is_instance_valid(_panel):
|
||||
_panel.visible = _isOpen
|
||||
if _isOpen and autoRequestFriendList and not _hasRequestedFriendList:
|
||||
_request_initial_friend_list()
|
||||
|
||||
func is_panel_open() -> bool:
|
||||
return _isOpen
|
||||
|
||||
func toggle_panel() -> void:
|
||||
set_panel_open(not _isOpen)
|
||||
|
||||
func _build_ui() -> void:
|
||||
_panel = PanelContainer.new()
|
||||
_panel.name = "FriendListCard"
|
||||
_panel.custom_minimum_size = PANEL_SIZE
|
||||
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_panel.add_theme_stylebox_override("panel", _create_panel_style())
|
||||
add_child(_panel)
|
||||
|
||||
_anchor_panel()
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 18)
|
||||
margin.add_theme_constant_override("margin_top", 18)
|
||||
margin.add_theme_constant_override("margin_right", 18)
|
||||
margin.add_theme_constant_override("margin_bottom", 18)
|
||||
_panel.add_child(margin)
|
||||
|
||||
var content := VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 12)
|
||||
margin.add_child(content)
|
||||
|
||||
var header := HBoxContainer.new()
|
||||
header.custom_minimum_size = Vector2(0, 48)
|
||||
header.add_theme_constant_override("separation", 12)
|
||||
content.add_child(header)
|
||||
|
||||
var titleStack := VBoxContainer.new()
|
||||
titleStack.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
titleStack.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
titleStack.add_theme_constant_override("separation", 1)
|
||||
header.add_child(titleStack)
|
||||
|
||||
_titleLabel = Label.new()
|
||||
_titleLabel.text = "好友"
|
||||
_titleLabel.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
_titleLabel.add_theme_font_size_override("font_size", 22)
|
||||
titleStack.add_child(_titleLabel)
|
||||
|
||||
var subtitleLabel := Label.new()
|
||||
subtitleLabel.text = "和鲸落镇的伙伴保持联系"
|
||||
subtitleLabel.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
subtitleLabel.add_theme_font_size_override("font_size", 12)
|
||||
titleStack.add_child(subtitleLabel)
|
||||
|
||||
_closeButton = Button.new()
|
||||
_closeButton.text = "×"
|
||||
_closeButton.tooltip_text = "收起好友列表"
|
||||
_closeButton.custom_minimum_size = Vector2(36, 36)
|
||||
_closeButton.focus_mode = Control.FOCUS_NONE
|
||||
_closeButton.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
_closeButton.add_theme_font_size_override("font_size", 22)
|
||||
_closeButton.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
_closeButton.add_theme_color_override("font_hover_color", ACCENT_COLOR)
|
||||
_closeButton.add_theme_color_override("font_pressed_color", ACCENT_COLOR.darkened(0.12))
|
||||
_closeButton.add_theme_color_override("font_disabled_color", Color(0.37, 0.48, 0.58, 0.55))
|
||||
_closeButton.add_theme_stylebox_override("normal", _create_empty_style())
|
||||
_closeButton.add_theme_stylebox_override("hover", _create_pill_style(Color(0.918, 0.961, 0.992, 0.96), 12))
|
||||
_closeButton.add_theme_stylebox_override("pressed", _create_pill_style(Color(0.858, 0.925, 0.980, 1.0), 12))
|
||||
_closeButton.add_theme_stylebox_override("focus", _create_empty_style())
|
||||
_closeButton.pressed.connect(func() -> void: set_panel_open(false))
|
||||
header.add_child(_closeButton)
|
||||
|
||||
var summaryCard := PanelContainer.new()
|
||||
summaryCard.custom_minimum_size = Vector2(0, 54)
|
||||
summaryCard.add_theme_stylebox_override("panel", _create_summary_style())
|
||||
content.add_child(summaryCard)
|
||||
|
||||
var summaryRow := HBoxContainer.new()
|
||||
summaryRow.add_theme_constant_override("separation", 10)
|
||||
summaryCard.add_child(summaryRow)
|
||||
|
||||
var onlineDot := PanelContainer.new()
|
||||
onlineDot.custom_minimum_size = Vector2(10, 10)
|
||||
onlineDot.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
onlineDot.add_theme_stylebox_override("panel", _create_dot_style(ONLINE_COLOR))
|
||||
summaryRow.add_child(onlineDot)
|
||||
|
||||
_summaryLabel = Label.new()
|
||||
_summaryLabel.text = "暂无在线好友"
|
||||
_summaryLabel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_summaryLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_summaryLabel.add_theme_color_override("font_color", Color(0.207843, 0.427451, 0.686275))
|
||||
_summaryLabel.add_theme_font_size_override("font_size", 15)
|
||||
summaryRow.add_child(_summaryLabel)
|
||||
|
||||
var chatHint := Label.new()
|
||||
chatHint.text = "选择好友开始私聊"
|
||||
chatHint.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
chatHint.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
chatHint.add_theme_font_size_override("font_size", 12)
|
||||
summaryRow.add_child(chatHint)
|
||||
|
||||
_statusLabel = Label.new()
|
||||
_statusLabel.visible = false
|
||||
_statusLabel.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_statusLabel.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
_statusLabel.add_theme_font_size_override("font_size", 15)
|
||||
content.add_child(_statusLabel)
|
||||
|
||||
_requestList = VBoxContainer.new()
|
||||
_requestList.add_theme_constant_override("separation", 8)
|
||||
content.add_child(_requestList)
|
||||
|
||||
var friendsHeading := Label.new()
|
||||
friendsHeading.text = "全部好友"
|
||||
friendsHeading.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
friendsHeading.add_theme_font_size_override("font_size", 13)
|
||||
content.add_child(friendsHeading)
|
||||
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
scroll.add_theme_stylebox_override("panel", _create_empty_style())
|
||||
content.add_child(scroll)
|
||||
|
||||
_friendList = VBoxContainer.new()
|
||||
_friendList.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_friendList.add_theme_constant_override("separation", 8)
|
||||
scroll.add_child(_friendList)
|
||||
|
||||
_render()
|
||||
|
||||
func _anchor_panel() -> void:
|
||||
_panel.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
|
||||
_panel.offset_left = -PANEL_SIZE.x - PANEL_MARGIN.x
|
||||
_panel.offset_top = -PANEL_SIZE.y - PANEL_MARGIN.y
|
||||
_panel.offset_right = -PANEL_MARGIN.x
|
||||
_panel.offset_bottom = -PANEL_MARGIN.y
|
||||
_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
||||
_panel.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
||||
|
||||
func _subscribe_to_events() -> void:
|
||||
var eventSystem := _get_event_system()
|
||||
if eventSystem == null:
|
||||
push_warning("FriendListPanel: EventSystem autoload is not available.")
|
||||
return
|
||||
|
||||
eventSystem.call("connect_event", EventNames.CHAT_FRIENDS_UPDATED, _on_friends_updated, self)
|
||||
eventSystem.call("connect_event", EventNames.HUD_FRIEND_LIST_TOGGLE, _on_friend_list_toggle, self)
|
||||
eventSystem.call("connect_event", EventNames.SETTINGS_CHANGED, _on_settings_changed, self)
|
||||
|
||||
func _request_initial_friend_list() -> void:
|
||||
_hasRequestedFriendList = true
|
||||
var chatManager := get_node_or_null("/root/ChatManager")
|
||||
if chatManager != null and chatManager.has_method("request_friend_list"):
|
||||
chatManager.call_deferred("request_friend_list")
|
||||
|
||||
func _on_friend_list_toggle(_data: Variant = null) -> void:
|
||||
toggle_panel()
|
||||
|
||||
func _on_friends_updated(data: Dictionary) -> void:
|
||||
_friends.clear()
|
||||
_requests.clear()
|
||||
_supported = bool(data.get("supported", true))
|
||||
_statusMessage = str(data.get("status", "")).strip_edges()
|
||||
|
||||
var friendsVariant: Variant = data.get("friends", [])
|
||||
if friendsVariant is Array:
|
||||
for friendVariant in friendsVariant:
|
||||
if friendVariant is Dictionary:
|
||||
_friends.append(friendVariant)
|
||||
|
||||
var requestsVariant: Variant = data.get("requests", [])
|
||||
if requestsVariant is Array:
|
||||
for requestVariant in requestsVariant:
|
||||
if requestVariant is Dictionary:
|
||||
_requests.append(requestVariant)
|
||||
|
||||
_render()
|
||||
|
||||
func _on_settings_changed(_data: Dictionary) -> void:
|
||||
_render()
|
||||
|
||||
func _render() -> void:
|
||||
if not is_instance_valid(_friendList) or not is_instance_valid(_requestList):
|
||||
return
|
||||
|
||||
_clear_children(_friendList)
|
||||
_clear_children(_requestList)
|
||||
|
||||
var onlineCount := _count_online_friends()
|
||||
_titleLabel.text = "好友"
|
||||
if is_instance_valid(_summaryLabel):
|
||||
if _friends.is_empty():
|
||||
_summaryLabel.text = "暂无好友,去广场认识新伙伴吧"
|
||||
else:
|
||||
_summaryLabel.text = "%d 位好友在线,共 %d 位伙伴" % [onlineCount, _friends.size()]
|
||||
|
||||
if not _supported:
|
||||
_show_status(_statusMessage if not _statusMessage.is_empty() else "当前后端暂不支持好友功能")
|
||||
return
|
||||
|
||||
_statusLabel.visible = false
|
||||
_render_requests()
|
||||
_render_friends()
|
||||
|
||||
func _render_requests() -> void:
|
||||
if _requests.is_empty() or not _settings_bool("friend_request_notifications", true):
|
||||
_requestList.visible = false
|
||||
return
|
||||
|
||||
_requestList.visible = true
|
||||
var titleCard := PanelContainer.new()
|
||||
titleCard.custom_minimum_size = Vector2(0, 34)
|
||||
titleCard.add_theme_stylebox_override("panel", _create_section_style())
|
||||
_requestList.add_child(titleCard)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "待处理请求 %d" % _requests.size()
|
||||
title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
title.add_theme_color_override("font_color", Color(0.207843, 0.427451, 0.686275))
|
||||
title.add_theme_font_size_override("font_size", 13)
|
||||
titleCard.add_child(title)
|
||||
|
||||
for request in _requests:
|
||||
_requestList.add_child(_create_request_row(request))
|
||||
|
||||
func _render_friends() -> void:
|
||||
if _friends.is_empty():
|
||||
var emptyCard := PanelContainer.new()
|
||||
emptyCard.custom_minimum_size = Vector2(0, 100)
|
||||
emptyCard.add_theme_stylebox_override("panel", _create_empty_state_style())
|
||||
_friendList.add_child(emptyCard)
|
||||
|
||||
var emptyLabel := Label.new()
|
||||
emptyLabel.text = "暂无好友\n靠近玩家按 F 发送好友请求"
|
||||
emptyLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
emptyLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
emptyLabel.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
emptyLabel.add_theme_font_size_override("font_size", 14)
|
||||
emptyCard.add_child(emptyLabel)
|
||||
return
|
||||
|
||||
for friend in _sorted_friends():
|
||||
_friendList.add_child(_create_friend_row(friend))
|
||||
|
||||
func _create_friend_row(friend: Dictionary) -> Control:
|
||||
var userId := _read_user_id(friend)
|
||||
var username := str(friend.get("username", "好友")).strip_edges()
|
||||
if username.is_empty():
|
||||
username = "好友"
|
||||
var online := bool(friend.get("online", false))
|
||||
|
||||
var button := Button.new()
|
||||
button.focus_mode = Control.FOCUS_NONE
|
||||
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
button.disabled = userId.is_empty()
|
||||
button.custom_minimum_size = Vector2(0, 68)
|
||||
button.text = ""
|
||||
button.add_theme_stylebox_override("normal", _create_friend_row_style(false, online))
|
||||
button.add_theme_stylebox_override("hover", _create_friend_row_style(true, online))
|
||||
button.add_theme_stylebox_override("pressed", _create_friend_row_style(true, online, true))
|
||||
button.add_theme_stylebox_override("disabled", _create_friend_row_style(false, false))
|
||||
button.add_theme_stylebox_override("focus", _create_empty_style())
|
||||
button.pressed.connect(func() -> void: _select_friend(userId, username, online))
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
row.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
row.offset_left = 10
|
||||
row.offset_top = 8
|
||||
row.offset_right = -10
|
||||
row.offset_bottom = -8
|
||||
row.add_theme_constant_override("separation", 12)
|
||||
button.add_child(row)
|
||||
|
||||
row.add_child(_create_avatar(username, online))
|
||||
|
||||
var textBox := VBoxContainer.new()
|
||||
textBox.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
textBox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
textBox.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
textBox.add_theme_constant_override("separation", 3)
|
||||
row.add_child(textBox)
|
||||
|
||||
var nameLabel := Label.new()
|
||||
nameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
nameLabel.text = username
|
||||
nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
nameLabel.add_theme_color_override("font_color", TEXT_COLOR if online else MUTED_COLOR)
|
||||
nameLabel.add_theme_font_size_override("font_size", 16)
|
||||
textBox.add_child(nameLabel)
|
||||
|
||||
var statusLabel := Label.new()
|
||||
statusLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
statusLabel.text = "正在鲸落镇" if online else "暂时离线"
|
||||
statusLabel.add_theme_color_override("font_color", ONLINE_COLOR if online else MUTED_COLOR)
|
||||
statusLabel.add_theme_font_size_override("font_size", 12)
|
||||
textBox.add_child(statusLabel)
|
||||
|
||||
var dot := PanelContainer.new()
|
||||
dot.name = "FriendOnlineIndicator"
|
||||
dot.custom_minimum_size = Vector2(8, 8)
|
||||
dot.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
dot.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
dot.add_theme_stylebox_override("panel", _create_dot_style(ONLINE_COLOR if online else OFFLINE_COLOR))
|
||||
row.add_child(dot)
|
||||
|
||||
return button
|
||||
|
||||
func _create_request_row(request: Dictionary) -> Control:
|
||||
var userId := _read_user_id(request)
|
||||
var username := str(request.get("username", "玩家")).strip_edges()
|
||||
if username.is_empty():
|
||||
username = "玩家"
|
||||
|
||||
var rowPanel := PanelContainer.new()
|
||||
rowPanel.custom_minimum_size = Vector2(0, 48)
|
||||
rowPanel.add_theme_stylebox_override("panel", _create_request_style())
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 8)
|
||||
rowPanel.add_child(row)
|
||||
|
||||
var nameLabel := Label.new()
|
||||
nameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
nameLabel.text = username
|
||||
nameLabel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
nameLabel.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
nameLabel.add_theme_font_size_override("font_size", 14)
|
||||
row.add_child(nameLabel)
|
||||
|
||||
var acceptButton := _create_small_button("接受", Color(0.333, 0.761, 0.514))
|
||||
acceptButton.pressed.connect(func() -> void: _respond_request(userId, username, true))
|
||||
row.add_child(acceptButton)
|
||||
|
||||
var rejectButton := _create_small_button("拒绝", Color(0.812, 0.553, 0.553))
|
||||
rejectButton.pressed.connect(func() -> void: _respond_request(userId, username, false))
|
||||
row.add_child(rejectButton)
|
||||
|
||||
return rowPanel
|
||||
|
||||
func _select_friend(userId: String, username: String, _online: bool) -> void:
|
||||
if userId.strip_edges().is_empty():
|
||||
return
|
||||
|
||||
var eventSystem := _get_event_system()
|
||||
if eventSystem != null:
|
||||
eventSystem.call("emit_event", EventNames.CHAT_FRIEND_SELECTED, {
|
||||
"user_id": userId,
|
||||
"username": username
|
||||
})
|
||||
|
||||
func _respond_request(userId: String, username: String, accepted: bool) -> void:
|
||||
if userId.strip_edges().is_empty():
|
||||
return
|
||||
|
||||
var chatManager := get_node_or_null("/root/ChatManager")
|
||||
if chatManager == null:
|
||||
return
|
||||
|
||||
var ok := false
|
||||
if accepted and chatManager.has_method("accept_friend_request"):
|
||||
ok = bool(chatManager.call("accept_friend_request", userId, username))
|
||||
elif not accepted and chatManager.has_method("reject_friend_request"):
|
||||
ok = bool(chatManager.call("reject_friend_request", userId, username))
|
||||
|
||||
if ok:
|
||||
for i in range(_requests.size() - 1, -1, -1):
|
||||
if _read_user_id(_requests[i]) == userId:
|
||||
_requests.remove_at(i)
|
||||
_render()
|
||||
|
||||
func _show_status(message: String) -> void:
|
||||
_statusLabel.text = message
|
||||
_statusLabel.visible = true
|
||||
_requestList.visible = false
|
||||
|
||||
func _count_online_friends() -> int:
|
||||
var count := 0
|
||||
for friend in _friends:
|
||||
if bool(friend.get("online", false)):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
func _sorted_friends() -> Array[Dictionary]:
|
||||
var sortedFriends := _friends.duplicate(true)
|
||||
sortedFriends.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
var onlineA := bool(a.get("online", false))
|
||||
var onlineB := bool(b.get("online", false))
|
||||
if onlineA != onlineB:
|
||||
return onlineA
|
||||
return str(a.get("username", "")).nocasecmp_to(str(b.get("username", ""))) < 0
|
||||
)
|
||||
return sortedFriends
|
||||
|
||||
func _read_user_id(data: Dictionary) -> String:
|
||||
return str(data.get("user_id", data.get("userId", ""))).strip_edges()
|
||||
|
||||
func _create_avatar(username: String, online: bool) -> PanelContainer:
|
||||
var avatar := PanelContainer.new()
|
||||
avatar.name = "FriendAvatarPanel"
|
||||
avatar.custom_minimum_size = FRIEND_AVATAR_SIZE
|
||||
avatar.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||
avatar.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
avatar.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
avatar.add_theme_stylebox_override("panel", _create_avatar_style(online))
|
||||
|
||||
var label := Label.new()
|
||||
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
label.text = _avatar_text(username)
|
||||
label.custom_minimum_size = FRIEND_AVATAR_SIZE
|
||||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
label.add_theme_color_override("font_color", Color.WHITE)
|
||||
label.add_theme_font_size_override("font_size", 16)
|
||||
avatar.add_child(label)
|
||||
|
||||
return avatar
|
||||
|
||||
func _avatar_text(username: String) -> String:
|
||||
var normalized := username.strip_edges()
|
||||
if normalized.is_empty():
|
||||
return "友"
|
||||
return normalized.substr(0, 1).to_upper()
|
||||
|
||||
func _create_small_button(text: String, color: Color) -> Button:
|
||||
var button := Button.new()
|
||||
button.text = text
|
||||
button.custom_minimum_size = Vector2(52, 30)
|
||||
button.focus_mode = Control.FOCUS_NONE
|
||||
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
button.add_theme_font_size_override("font_size", 13)
|
||||
button.add_theme_color_override("font_color", Color.WHITE)
|
||||
button.add_theme_stylebox_override("normal", _create_pill_style(color, 9))
|
||||
button.add_theme_stylebox_override("hover", _create_pill_style(color.lightened(0.08), 9))
|
||||
button.add_theme_stylebox_override("pressed", _create_pill_style(color.darkened(0.08), 9))
|
||||
button.add_theme_stylebox_override("focus", _create_empty_style())
|
||||
return button
|
||||
|
||||
func _create_panel_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = SURFACE_COLOR
|
||||
style.border_width_left = 1
|
||||
style.border_width_top = 1
|
||||
style.border_width_right = 1
|
||||
style.border_width_bottom = 1
|
||||
style.border_color = BORDER_COLOR
|
||||
style.corner_radius_top_left = 20
|
||||
style.corner_radius_top_right = 20
|
||||
style.corner_radius_bottom_left = 20
|
||||
style.corner_radius_bottom_right = 20
|
||||
style.shadow_color = Color(0.12549, 0.282353, 0.407843, 0.18)
|
||||
style.shadow_size = 18
|
||||
style.shadow_offset = Vector2(0, 7)
|
||||
return style
|
||||
|
||||
func _create_avatar_style(online: bool) -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.361, 0.690, 0.914) if online else Color(0.678, 0.725, 0.773)
|
||||
style.corner_radius_top_left = FRIEND_AVATAR_CORNER_RADIUS
|
||||
style.corner_radius_top_right = FRIEND_AVATAR_CORNER_RADIUS
|
||||
style.corner_radius_bottom_left = FRIEND_AVATAR_CORNER_RADIUS
|
||||
style.corner_radius_bottom_right = FRIEND_AVATAR_CORNER_RADIUS
|
||||
style.border_width_left = 1
|
||||
style.border_width_top = 1
|
||||
style.border_width_right = 1
|
||||
style.border_width_bottom = 1
|
||||
style.border_color = Color(1, 1, 1, 0.82)
|
||||
return style
|
||||
|
||||
func _create_summary_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.918, 0.961, 0.992, 0.96)
|
||||
style.border_width_left = 1
|
||||
style.border_width_top = 1
|
||||
style.border_width_right = 1
|
||||
style.border_width_bottom = 1
|
||||
style.border_color = Color(0.76, 0.86, 0.94, 0.62)
|
||||
style.corner_radius_top_left = 12
|
||||
style.corner_radius_top_right = 12
|
||||
style.corner_radius_bottom_left = 12
|
||||
style.corner_radius_bottom_right = 12
|
||||
style.content_margin_left = 14
|
||||
style.content_margin_right = 14
|
||||
return style
|
||||
|
||||
func _create_dot_style(color: Color) -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = color
|
||||
style.corner_radius_top_left = 5
|
||||
style.corner_radius_top_right = 5
|
||||
style.corner_radius_bottom_left = 5
|
||||
style.corner_radius_bottom_right = 5
|
||||
return style
|
||||
|
||||
func _create_section_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.948, 0.974, 0.996, 0.96)
|
||||
style.border_width_left = 1
|
||||
style.border_width_top = 1
|
||||
style.border_width_right = 1
|
||||
style.border_width_bottom = 1
|
||||
style.border_color = Color(0.78, 0.87, 0.94, 0.54)
|
||||
style.corner_radius_top_left = 9
|
||||
style.corner_radius_top_right = 9
|
||||
style.corner_radius_bottom_left = 9
|
||||
style.corner_radius_bottom_right = 9
|
||||
style.content_margin_left = 12
|
||||
style.content_margin_right = 12
|
||||
return style
|
||||
|
||||
func _create_empty_state_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.965, 0.982, 0.996, 0.86)
|
||||
style.border_width_left = 1
|
||||
style.border_width_top = 1
|
||||
style.border_width_right = 1
|
||||
style.border_width_bottom = 1
|
||||
style.border_color = Color(0.80, 0.88, 0.94, 0.52)
|
||||
style.corner_radius_top_left = 12
|
||||
style.corner_radius_top_right = 12
|
||||
style.corner_radius_bottom_left = 12
|
||||
style.corner_radius_bottom_right = 12
|
||||
return style
|
||||
|
||||
func _create_request_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(1, 1, 1, 0.94)
|
||||
style.border_width_left = 1
|
||||
style.border_width_top = 1
|
||||
style.border_width_right = 1
|
||||
style.border_width_bottom = 1
|
||||
style.border_color = Color(0.78, 0.86, 0.93, 0.64)
|
||||
style.corner_radius_top_left = 10
|
||||
style.corner_radius_top_right = 10
|
||||
style.corner_radius_bottom_left = 10
|
||||
style.corner_radius_bottom_right = 10
|
||||
style.content_margin_left = 10
|
||||
style.content_margin_top = 8
|
||||
style.content_margin_right = 10
|
||||
style.content_margin_bottom = 8
|
||||
return style
|
||||
|
||||
func _create_friend_row_style(isHovered: bool, isOnline: bool, isPressed: bool = false) -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = SURFACE_RAISED_COLOR if isOnline else Color(0.965, 0.973, 0.982, 0.86)
|
||||
if isHovered:
|
||||
style.bg_color = Color(0.918, 0.961, 0.992, 0.98)
|
||||
if isPressed:
|
||||
style.bg_color = style.bg_color.darkened(0.12)
|
||||
style.border_width_left = 1
|
||||
style.border_width_top = 1
|
||||
style.border_width_right = 1
|
||||
style.border_width_bottom = 1
|
||||
style.border_color = Color(0.55, 0.78, 0.95, 0.72) if isHovered else Color(0.80, 0.87, 0.93, 0.56)
|
||||
style.corner_radius_top_left = 12
|
||||
style.corner_radius_top_right = 12
|
||||
style.corner_radius_bottom_left = 12
|
||||
style.corner_radius_bottom_right = 12
|
||||
return style
|
||||
|
||||
func _create_pill_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.content_margin_left = 10
|
||||
style.content_margin_right = 10
|
||||
style.content_margin_top = 5
|
||||
style.content_margin_bottom = 5
|
||||
return style
|
||||
|
||||
func _create_empty_style() -> StyleBoxEmpty:
|
||||
return StyleBoxEmpty.new()
|
||||
|
||||
func _clear_children(container: Node) -> void:
|
||||
for child in container.get_children():
|
||||
container.remove_child(child)
|
||||
child.queue_free()
|
||||
|
||||
func _get_event_system() -> Node:
|
||||
return get_node_or_null("/root/EventSystem")
|
||||
|
||||
func _settings_bool(key: String, defaultValue: bool) -> bool:
|
||||
var settingsManager := get_node_or_null("/root/SettingsManager")
|
||||
if settingsManager != null and settingsManager.has_method("get_bool"):
|
||||
return bool(settingsManager.call("get_bool", key))
|
||||
return defaultValue
|
||||
1
scenes/ui/FriendListPanel.gd.uid
Normal file
1
scenes/ui/FriendListPanel.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dgu3rem7rhdfa
|
||||
13
scenes/ui/FriendListPanel.tscn
Normal file
13
scenes/ui/FriendListPanel.tscn
Normal file
@@ -0,0 +1,13 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/ui/FriendListPanel.gd" id="1"]
|
||||
|
||||
[node name="FriendListPanel" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1")
|
||||
143
scenes/ui/HudShortcutIcon.gd
Normal file
143
scenes/ui/HudShortcutIcon.gd
Normal file
@@ -0,0 +1,143 @@
|
||||
extends Control
|
||||
|
||||
# ============================================================================
|
||||
# HudShortcutIcon.gd - 顶部快捷入口轻量线描图标
|
||||
# ============================================================================
|
||||
# 专门按 25px HUD 小尺寸绘制,保持细线、少细节、轻视觉重量。
|
||||
# ============================================================================
|
||||
|
||||
const LINE_COLOR: Color = Color(0.592157, 0.72549, 0.835294, 0.82)
|
||||
const DOT_COLOR: Color = Color(0.941176, 0.54902, 0.54902, 0.86)
|
||||
const DOT_BORDER_COLOR: Color = Color(1.0, 1.0, 1.0, 0.95)
|
||||
const LINE_WIDTH: float = 0.85
|
||||
const DETAIL_WIDTH: float = 0.75
|
||||
const BASE_SIZE: float = 27.0
|
||||
|
||||
var iconName: String = "map"
|
||||
var _iconScale: float = 1.0
|
||||
var _iconOffset: Vector2 = Vector2.ZERO
|
||||
|
||||
func _ready() -> void:
|
||||
custom_minimum_size = Vector2(31, 31)
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
func set_icon_name(value: String) -> void:
|
||||
iconName = value
|
||||
queue_redraw()
|
||||
|
||||
func _draw() -> void:
|
||||
_iconScale = min(size.x, size.y) / BASE_SIZE
|
||||
_iconOffset = (size - Vector2(BASE_SIZE, BASE_SIZE) * _iconScale) * 0.5
|
||||
|
||||
match iconName:
|
||||
"map":
|
||||
_draw_map()
|
||||
"task":
|
||||
_draw_task()
|
||||
"activity":
|
||||
_draw_activity()
|
||||
"backpack":
|
||||
_draw_backpack()
|
||||
"friends":
|
||||
_draw_friends()
|
||||
"settings":
|
||||
_draw_settings()
|
||||
_:
|
||||
_draw_map()
|
||||
|
||||
func _draw_map() -> void:
|
||||
_draw_polyline([
|
||||
Vector2(6.0, 7.0),
|
||||
Vector2(10.9, 5.4),
|
||||
Vector2(16.2, 7.1),
|
||||
Vector2(21.0, 5.5),
|
||||
Vector2(21.0, 20.1),
|
||||
Vector2(16.2, 21.6),
|
||||
Vector2(10.9, 19.9),
|
||||
Vector2(6.0, 21.5),
|
||||
Vector2(6.0, 7.0),
|
||||
])
|
||||
_draw_line(Vector2(10.9, 6.0), Vector2(10.9, 19.2), DETAIL_WIDTH)
|
||||
_draw_line(Vector2(16.2, 7.7), Vector2(16.2, 21.0), DETAIL_WIDTH)
|
||||
|
||||
func _draw_task() -> void:
|
||||
_draw_round_rect(Rect2(7.3, 6.2, 12.4, 15.4), 2.2)
|
||||
_draw_line(Vector2(11.0, 5.0), Vector2(16.0, 5.0), LINE_WIDTH)
|
||||
_draw_line(Vector2(9.8, 11.0), Vector2(16.8, 11.0), DETAIL_WIDTH)
|
||||
_draw_line(Vector2(9.8, 14.8), Vector2(16.8, 14.8), DETAIL_WIDTH)
|
||||
_draw_line(Vector2(9.8, 18.6), Vector2(14.8, 18.6), DETAIL_WIDTH)
|
||||
_draw_red_dot(Vector2(20.0, 5.6))
|
||||
|
||||
func _draw_activity() -> void:
|
||||
_draw_round_rect(Rect2(6.2, 7.5, 14.1, 13.2), 2.2)
|
||||
_draw_line(Vector2(6.9, 11.5), Vector2(19.5, 11.5), DETAIL_WIDTH)
|
||||
_draw_line(Vector2(9.6, 5.5), Vector2(9.6, 9.0), DETAIL_WIDTH)
|
||||
_draw_line(Vector2(16.7, 5.5), Vector2(16.7, 9.0), DETAIL_WIDTH)
|
||||
_draw_line(Vector2(10.0, 15.6), Vector2(12.0, 15.6), DETAIL_WIDTH)
|
||||
_draw_line(Vector2(14.6, 15.6), Vector2(16.6, 15.6), DETAIL_WIDTH)
|
||||
_draw_red_dot(Vector2(20.4, 5.8))
|
||||
|
||||
func _draw_backpack() -> void:
|
||||
_draw_round_rect(Rect2(6.4, 8.4, 14.2, 12.6), 2.4)
|
||||
_draw_arc_poly(Vector2(13.5, 9.0), 4.0, PI + 0.18, TAU - 0.18, 18, DETAIL_WIDTH)
|
||||
_draw_line(Vector2(8.9, 12.4), Vector2(18.1, 12.4), DETAIL_WIDTH)
|
||||
_draw_round_rect(Rect2(10.4, 13.9, 6.2, 4.4), 1.2, DETAIL_WIDTH)
|
||||
_draw_line(Vector2(8.2, 10.2), Vector2(8.2, 20.0), DETAIL_WIDTH)
|
||||
_draw_line(Vector2(18.8, 10.2), Vector2(18.8, 20.0), DETAIL_WIDTH)
|
||||
|
||||
func _draw_friends() -> void:
|
||||
_draw_arc(Vector2(10.2, 9.4), 3.1, 0.0, TAU, 28, LINE_WIDTH)
|
||||
_draw_arc_poly(Vector2(10.2, 21.6), 6.1, PI + 0.10, TAU - 0.10, 22)
|
||||
_draw_arc(Vector2(17.8, 10.6), 2.5, 0.0, TAU, 24, DETAIL_WIDTH)
|
||||
_draw_arc_poly(Vector2(17.8, 21.6), 4.7, PI + 0.16, TAU - 0.24, 18, DETAIL_WIDTH)
|
||||
|
||||
func _draw_settings() -> void:
|
||||
var center := Vector2(13.5, 13.5)
|
||||
for i in range(8):
|
||||
var angle := TAU * float(i) / 8.0
|
||||
var inner := center + Vector2(cos(angle), sin(angle)) * 5.0
|
||||
var outer := center + Vector2(cos(angle), sin(angle)) * 6.7
|
||||
_draw_line(inner, outer, DETAIL_WIDTH)
|
||||
_draw_arc(center, 4.9, 0.0, TAU, 32, LINE_WIDTH)
|
||||
_draw_arc(center, 2.0, 0.0, TAU, 24, DETAIL_WIDTH)
|
||||
|
||||
func _draw_round_rect(rect: Rect2, radius: float, width: float = LINE_WIDTH) -> void:
|
||||
var left := rect.position.x
|
||||
var top := rect.position.y
|
||||
var right := rect.end.x
|
||||
var bottom := rect.end.y
|
||||
_draw_line(Vector2(left + radius, top), Vector2(right - radius, top), width)
|
||||
_draw_line(Vector2(right, top + radius), Vector2(right, bottom - radius), width)
|
||||
_draw_line(Vector2(right - radius, bottom), Vector2(left + radius, bottom), width)
|
||||
_draw_line(Vector2(left, bottom - radius), Vector2(left, top + radius), width)
|
||||
_draw_arc(Vector2(left + radius, top + radius), radius, PI, PI * 1.5, 8, width)
|
||||
_draw_arc(Vector2(right - radius, top + radius), radius, PI * 1.5, TAU, 8, width)
|
||||
_draw_arc(Vector2(right - radius, bottom - radius), radius, 0.0, PI * 0.5, 8, width)
|
||||
_draw_arc(Vector2(left + radius, bottom - radius), radius, PI * 0.5, PI, 8, width)
|
||||
|
||||
func _draw_arc_poly(center: Vector2, radius: float, start: float, end: float, pointCount: int, width: float = LINE_WIDTH) -> void:
|
||||
var points: PackedVector2Array = []
|
||||
for i in range(pointCount + 1):
|
||||
var t := float(i) / float(pointCount)
|
||||
var angle := lerpf(start, end, t)
|
||||
points.append(_p(center + Vector2(cos(angle), sin(angle)) * radius))
|
||||
draw_polyline(points, LINE_COLOR, width, true)
|
||||
|
||||
func _draw_polyline(points: Array[Vector2], width: float = LINE_WIDTH) -> void:
|
||||
var packed: PackedVector2Array = []
|
||||
for point in points:
|
||||
packed.append(_p(point))
|
||||
draw_polyline(packed, LINE_COLOR, width, true)
|
||||
|
||||
func _draw_red_dot(center: Vector2) -> void:
|
||||
draw_circle(_p(center), 2.3 * _iconScale, DOT_BORDER_COLOR)
|
||||
draw_circle(_p(center), 1.55 * _iconScale, DOT_COLOR)
|
||||
|
||||
func _draw_line(from: Vector2, to: Vector2, width: float) -> void:
|
||||
draw_line(_p(from), _p(to), LINE_COLOR, width, true)
|
||||
|
||||
func _draw_arc(center: Vector2, radius: float, startAngle: float, endAngle: float, pointCount: int, width: float) -> void:
|
||||
draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, LINE_COLOR, width, true)
|
||||
|
||||
func _p(point: Vector2) -> Vector2:
|
||||
return _iconOffset + point * _iconScale
|
||||
1
scenes/ui/HudShortcutIcon.gd.uid
Normal file
1
scenes/ui/HudShortcutIcon.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://de885djlotpxs
|
||||
516
scenes/ui/MapPanel.gd
Normal file
516
scenes/ui/MapPanel.gd
Normal file
@@ -0,0 +1,516 @@
|
||||
extends Control
|
||||
|
||||
# ============================================================================
|
||||
# MapPanel.gd - 右上角小镇地图弹窗
|
||||
# ============================================================================
|
||||
# 监听 HUD 地图入口,展示当前区域全图与关键建筑标识。
|
||||
# ============================================================================
|
||||
|
||||
const PANEL_SIZE: Vector2 = Vector2(548, 452)
|
||||
const PANEL_MARGIN_TOP: float = 122.0
|
||||
const PANEL_MARGIN_RIGHT: float = 56.0
|
||||
const MAP_VIEW_SIZE: Vector2 = Vector2(500, 375)
|
||||
const MAP_WORLD_SIZE: Vector2 = Vector2(2560, 1920)
|
||||
const MAP_CAMERA_ZOOM: Vector2 = Vector2(MAP_VIEW_SIZE.x / MAP_WORLD_SIZE.x, MAP_VIEW_SIZE.y / MAP_WORLD_SIZE.y)
|
||||
const MINIMAP_EXCLUDED_NODE_NAMES: Array[String] = [
|
||||
"Characters",
|
||||
"MultiplayerController",
|
||||
]
|
||||
|
||||
const TEXT_COLOR: Color = Color(0.188, 0.294, 0.424)
|
||||
const MUTED_COLOR: Color = Color(0.560, 0.639, 0.733)
|
||||
const ACCENT_COLOR: Color = Color(0.258824, 0.627451, 0.913725)
|
||||
const SOFT_BLUE: Color = Color(0.918, 0.961, 0.992, 0.94)
|
||||
const PIN_BLUE: Color = Color(0.244, 0.572, 0.862)
|
||||
|
||||
const MAP_CONFIGS: Dictionary = {
|
||||
"Square": {
|
||||
"title": "小镇地图",
|
||||
"sourceNodes": [
|
||||
"HDGrassBase",
|
||||
"GroundGrassLayer",
|
||||
"PathBaseLayer",
|
||||
"PathEdgeLayer",
|
||||
"PathEdgeCornerLayer",
|
||||
"PathEdgeJoinLayer",
|
||||
"PathCurbLayer",
|
||||
"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"},
|
||||
],
|
||||
},
|
||||
"WorkZone": {
|
||||
"title": "打工区地图",
|
||||
"sourceNodes": [
|
||||
"StageBackdrop",
|
||||
"RoadPavementLayer",
|
||||
"RoadAsphaltLayer",
|
||||
"RoadCrosswalkLayer",
|
||||
"RoadMarkingLayer",
|
||||
"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"},
|
||||
],
|
||||
},
|
||||
"CafeInterior": {
|
||||
"title": "鲸鱼咖啡馆",
|
||||
"worldSize": Vector2(1536, 1024),
|
||||
"sourceNodes": [
|
||||
"StageBackdrop",
|
||||
"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"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
var _panel: PanelContainer
|
||||
var _mapFrame: PanelContainer
|
||||
var _mapLayer: Control
|
||||
var _mapViewport: SubViewport
|
||||
var _mapWorld: Node2D
|
||||
var _mapCamera: Camera2D
|
||||
var _titleLabel: Label
|
||||
var _markerNodes: Array[Control] = []
|
||||
var _isOpen: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_build_ui()
|
||||
set_panel_open(false)
|
||||
_subscribe_to_events()
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var eventSystem := _get_event_system()
|
||||
if eventSystem != null:
|
||||
eventSystem.call("disconnect_event", EventNames.HUD_MAP_TOGGLE, _on_map_toggle, self)
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_RESIZED and is_instance_valid(_panel):
|
||||
_anchor_panel()
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if not _isOpen:
|
||||
return
|
||||
if event is InputEventKey:
|
||||
var keyEvent := event as InputEventKey
|
||||
if keyEvent.pressed and not keyEvent.echo and keyEvent.keycode == KEY_ESCAPE:
|
||||
set_panel_open(false)
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
func set_panel_open(open: bool) -> void:
|
||||
_isOpen = open
|
||||
visible = _isOpen
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP if _isOpen else Control.MOUSE_FILTER_IGNORE
|
||||
if is_instance_valid(_panel):
|
||||
_panel.visible = _isOpen
|
||||
if _isOpen:
|
||||
_rebuild_minimap_world()
|
||||
|
||||
func is_panel_open() -> bool:
|
||||
return _isOpen
|
||||
|
||||
func toggle_panel() -> void:
|
||||
set_panel_open(not _isOpen)
|
||||
|
||||
func _build_ui() -> void:
|
||||
_panel = PanelContainer.new()
|
||||
_panel.name = "MapCard"
|
||||
_panel.custom_minimum_size = PANEL_SIZE
|
||||
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_panel.add_theme_stylebox_override("panel", _create_panel_style())
|
||||
add_child(_panel)
|
||||
_anchor_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", 22)
|
||||
_panel.add_child(margin)
|
||||
|
||||
var content := VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 12)
|
||||
margin.add_child(content)
|
||||
|
||||
content.add_child(_build_header())
|
||||
content.add_child(_build_map_view())
|
||||
|
||||
func _build_header() -> Control:
|
||||
var header := HBoxContainer.new()
|
||||
header.custom_minimum_size = Vector2(0, 42)
|
||||
header.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
header.add_theme_constant_override("separation", 12)
|
||||
|
||||
var icon := MapGlyph.new()
|
||||
icon.custom_minimum_size = Vector2(32, 32)
|
||||
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
header.add_child(icon)
|
||||
|
||||
var title := Label.new()
|
||||
title.name = "TitleLabel"
|
||||
_titleLabel = title
|
||||
title.text = "小镇地图"
|
||||
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
title.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
title.add_theme_font_size_override("font_size", 24)
|
||||
header.add_child(title)
|
||||
|
||||
var closeButton := Button.new()
|
||||
closeButton.text = "×"
|
||||
closeButton.tooltip_text = "关闭地图"
|
||||
closeButton.custom_minimum_size = Vector2(42, 42)
|
||||
closeButton.focus_mode = Control.FOCUS_NONE
|
||||
closeButton.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
closeButton.add_theme_font_size_override("font_size", 28)
|
||||
closeButton.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
closeButton.add_theme_color_override("font_hover_color", ACCENT_COLOR)
|
||||
closeButton.add_theme_color_override("font_pressed_color", ACCENT_COLOR.darkened(0.18))
|
||||
closeButton.add_theme_color_override("font_disabled_color", Color(0.520, 0.600, 0.680, 0.55))
|
||||
closeButton.add_theme_stylebox_override("normal", _create_round_style(SOFT_BLUE, 21))
|
||||
closeButton.add_theme_stylebox_override("hover", _create_round_style(Color(0.858, 0.925, 0.980, 1.0), 21))
|
||||
closeButton.add_theme_stylebox_override("pressed", _create_round_style(Color(0.778, 0.884, 0.965, 1.0), 21))
|
||||
closeButton.add_theme_stylebox_override("focus", StyleBoxEmpty.new())
|
||||
closeButton.pressed.connect(func() -> void: set_panel_open(false))
|
||||
header.add_child(closeButton)
|
||||
|
||||
return header
|
||||
|
||||
func _build_map_view() -> Control:
|
||||
_mapFrame = PanelContainer.new()
|
||||
_mapFrame.custom_minimum_size = MAP_VIEW_SIZE
|
||||
_mapFrame.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_mapFrame.add_theme_stylebox_override("panel", _create_map_frame_style())
|
||||
|
||||
_mapLayer = Control.new()
|
||||
_mapLayer.custom_minimum_size = MAP_VIEW_SIZE
|
||||
_mapLayer.clip_contents = true
|
||||
_mapLayer.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_mapFrame.add_child(_mapLayer)
|
||||
|
||||
var viewportContainer := SubViewportContainer.new()
|
||||
viewportContainer.name = "SquareMapViewportContainer"
|
||||
viewportContainer.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
viewportContainer.stretch = true
|
||||
viewportContainer.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_mapLayer.add_child(viewportContainer)
|
||||
|
||||
_mapViewport = SubViewport.new()
|
||||
_mapViewport.name = "SquareMapViewport"
|
||||
_mapViewport.size = Vector2i(int(MAP_VIEW_SIZE.x), int(MAP_VIEW_SIZE.y))
|
||||
_mapViewport.render_target_update_mode = SubViewport.UPDATE_WHEN_VISIBLE
|
||||
_mapViewport.world_2d = World2D.new()
|
||||
viewportContainer.add_child(_mapViewport)
|
||||
|
||||
_mapWorld = Node2D.new()
|
||||
_mapWorld.name = "RenderedMapWorld"
|
||||
_mapViewport.add_child(_mapWorld)
|
||||
|
||||
_mapCamera = Camera2D.new()
|
||||
_mapCamera.name = "MinimapCamera"
|
||||
_mapCamera.position = Vector2.ZERO
|
||||
_mapCamera.zoom = MAP_CAMERA_ZOOM
|
||||
_mapCamera.enabled = true
|
||||
_mapViewport.add_child(_mapCamera)
|
||||
|
||||
var wash := ColorRect.new()
|
||||
wash.name = "MapSoftWash"
|
||||
wash.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
wash.color = Color(1.0, 0.985, 0.930, 0.10)
|
||||
wash.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_mapLayer.add_child(wash)
|
||||
|
||||
_render_markers()
|
||||
|
||||
return _mapFrame
|
||||
|
||||
func _render_markers() -> void:
|
||||
for markerNode in _markerNodes:
|
||||
if is_instance_valid(markerNode):
|
||||
markerNode.queue_free()
|
||||
_markerNodes.clear()
|
||||
|
||||
for marker in _current_markers():
|
||||
var markerNode := _create_marker(marker)
|
||||
_markerNodes.append(markerNode)
|
||||
_mapLayer.add_child(markerNode)
|
||||
|
||||
func _create_marker(marker: Dictionary) -> Control:
|
||||
var button := Button.new()
|
||||
button.name = "Marker%s" % str(marker.get("label", ""))
|
||||
button.text = ""
|
||||
button.focus_mode = Control.FOCUS_NONE
|
||||
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
button.custom_minimum_size = Vector2(82, 34)
|
||||
button.add_theme_stylebox_override("normal", _create_marker_style(Color(1.0, 1.0, 1.0, 0.93), Color(0.650, 0.792, 0.910, 0.88)))
|
||||
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 row := HBoxContainer.new()
|
||||
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
row.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
row.offset_left = 8
|
||||
row.offset_right = -9
|
||||
row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
row.add_theme_constant_override("separation", 5)
|
||||
button.add_child(row)
|
||||
|
||||
var icon := MarkerGlyph.new()
|
||||
icon.set("iconName", str(marker.get("icon", "pin")))
|
||||
icon.custom_minimum_size = Vector2(20, 20)
|
||||
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
row.add_child(icon)
|
||||
|
||||
var label := Label.new()
|
||||
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
label.text = 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)
|
||||
|
||||
var position := marker.get("pos", Vector2.ZERO) as Vector2
|
||||
var buttonSize := button.custom_minimum_size
|
||||
var xOffset := 8.0 if str(marker.get("side", "right")) == "right" else -buttonSize.x - 8.0
|
||||
button.position = Vector2(
|
||||
position.x * MAP_VIEW_SIZE.x + xOffset,
|
||||
position.y * MAP_VIEW_SIZE.y - buttonSize.y * 0.5
|
||||
)
|
||||
return button
|
||||
|
||||
func _anchor_panel() -> void:
|
||||
_panel.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||
_panel.offset_left = -PANEL_SIZE.x - PANEL_MARGIN_RIGHT
|
||||
_panel.offset_top = PANEL_MARGIN_TOP
|
||||
_panel.offset_right = -PANEL_MARGIN_RIGHT
|
||||
_panel.offset_bottom = PANEL_MARGIN_TOP + PANEL_SIZE.y
|
||||
_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
||||
|
||||
func _subscribe_to_events() -> void:
|
||||
var eventSystem := _get_event_system()
|
||||
if eventSystem == null:
|
||||
push_warning("MapPanel: EventSystem autoload is not available.")
|
||||
return
|
||||
eventSystem.call("connect_event", EventNames.HUD_MAP_TOGGLE, _on_map_toggle, self)
|
||||
|
||||
func _on_map_toggle(_data: Variant = null) -> void:
|
||||
toggle_panel()
|
||||
|
||||
func _rebuild_minimap_world() -> void:
|
||||
if not is_instance_valid(_mapWorld):
|
||||
return
|
||||
_clear_children(_mapWorld)
|
||||
|
||||
var sourceScene := _find_source_scene()
|
||||
if sourceScene == null:
|
||||
push_warning("MapPanel: source scene for minimap is unavailable.")
|
||||
return
|
||||
_apply_scene_config(sourceScene)
|
||||
|
||||
for nodeName in _current_source_node_names(sourceScene):
|
||||
var sourceNode := sourceScene.get_node_or_null(nodeName)
|
||||
if sourceNode == null:
|
||||
continue
|
||||
var clone := sourceNode.duplicate(0)
|
||||
if clone == null:
|
||||
continue
|
||||
_remove_excluded_minimap_nodes(clone)
|
||||
_mapWorld.add_child(clone)
|
||||
|
||||
func _find_source_scene() -> Node:
|
||||
var node: Node = self
|
||||
while node != null:
|
||||
if MAP_CONFIGS.has(str(node.name)):
|
||||
return node
|
||||
node = node.get_parent()
|
||||
return get_tree().current_scene
|
||||
|
||||
func _apply_scene_config(sourceScene: Node) -> void:
|
||||
var config := _map_config_for_scene(sourceScene)
|
||||
if is_instance_valid(_titleLabel):
|
||||
_titleLabel.text = str(config.get("title", "区域地图"))
|
||||
_configure_map_camera(config)
|
||||
_render_markers()
|
||||
|
||||
func _configure_map_camera(config: Dictionary) -> void:
|
||||
if not is_instance_valid(_mapCamera):
|
||||
return
|
||||
var worldSize := config.get("worldSize", MAP_WORLD_SIZE) as Vector2
|
||||
if worldSize.x <= 0.0 or worldSize.y <= 0.0:
|
||||
worldSize = MAP_WORLD_SIZE
|
||||
var zoomScale := minf(MAP_VIEW_SIZE.x / worldSize.x, MAP_VIEW_SIZE.y / worldSize.y)
|
||||
_mapCamera.zoom = Vector2.ONE * zoomScale
|
||||
|
||||
func _current_source_node_names(sourceScene: Node) -> Array:
|
||||
var config := _map_config_for_scene(sourceScene)
|
||||
var nodeNames: Array = config.get("sourceNodes", [])
|
||||
return nodeNames
|
||||
|
||||
func _current_markers() -> Array:
|
||||
var config := _map_config_for_scene(_find_source_scene())
|
||||
var markers: Array = config.get("markers", [])
|
||||
return markers
|
||||
|
||||
func _map_config_for_scene(sourceScene: Node) -> Dictionary:
|
||||
if sourceScene != null:
|
||||
var sceneName := str(sourceScene.name)
|
||||
if MAP_CONFIGS.has(sceneName):
|
||||
return MAP_CONFIGS[sceneName]
|
||||
return MAP_CONFIGS["Square"]
|
||||
|
||||
func _remove_excluded_minimap_nodes(root: Node) -> void:
|
||||
for nodeName in MINIMAP_EXCLUDED_NODE_NAMES:
|
||||
var node := root.find_child(nodeName, true, false)
|
||||
if node != null and node.get_parent() != null:
|
||||
node.get_parent().remove_child(node)
|
||||
node.queue_free()
|
||||
|
||||
func _clear_children(container: Node) -> void:
|
||||
for child in container.get_children():
|
||||
container.remove_child(child)
|
||||
child.queue_free()
|
||||
|
||||
func _create_panel_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(1.0, 1.0, 1.0, 0.948)
|
||||
style.corner_radius_top_left = 28
|
||||
style.corner_radius_top_right = 28
|
||||
style.corner_radius_bottom_left = 28
|
||||
style.corner_radius_bottom_right = 28
|
||||
style.shadow_color = Color(0.12549, 0.282353, 0.407843, 0.18)
|
||||
style.shadow_size = 22
|
||||
style.shadow_offset = Vector2(0, 9)
|
||||
return style
|
||||
|
||||
func _create_map_frame_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.918, 0.961, 0.992, 0.96)
|
||||
style.border_color = Color(0.431, 0.733, 0.929, 0.86)
|
||||
style.border_width_left = 2
|
||||
style.border_width_top = 2
|
||||
style.border_width_right = 2
|
||||
style.border_width_bottom = 2
|
||||
style.corner_radius_top_left = 16
|
||||
style.corner_radius_top_right = 16
|
||||
style.corner_radius_bottom_left = 16
|
||||
style.corner_radius_bottom_right = 16
|
||||
return style
|
||||
|
||||
func _create_marker_style(color: Color, borderColor: Color) -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = color
|
||||
style.border_color = borderColor
|
||||
style.border_width_left = 2
|
||||
style.border_width_top = 2
|
||||
style.border_width_right = 2
|
||||
style.border_width_bottom = 2
|
||||
style.corner_radius_top_left = 18
|
||||
style.corner_radius_top_right = 18
|
||||
style.corner_radius_bottom_left = 18
|
||||
style.corner_radius_bottom_right = 18
|
||||
style.shadow_color = Color(0.078, 0.223, 0.360, 0.14)
|
||||
style.shadow_size = 7
|
||||
style.shadow_offset = Vector2(0, 3)
|
||||
return style
|
||||
|
||||
func _create_round_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
|
||||
return style
|
||||
|
||||
func _get_event_system() -> Node:
|
||||
return get_node_or_null("/root/EventSystem")
|
||||
|
||||
class MapGlyph:
|
||||
extends Control
|
||||
|
||||
func _draw() -> void:
|
||||
var color := Color(0.258824, 0.627451, 0.913725)
|
||||
var width := 2.2
|
||||
draw_polyline(PackedVector2Array([
|
||||
Vector2(6, 9), Vector2(12, 7), Vector2(19, 9), Vector2(26, 7),
|
||||
Vector2(26, 25), Vector2(19, 27), Vector2(12, 25), Vector2(6, 27),
|
||||
Vector2(6, 9)
|
||||
]), color, width, true)
|
||||
draw_line(Vector2(12, 8), Vector2(12, 24), color, 1.6, true)
|
||||
draw_line(Vector2(19, 10), Vector2(19, 26), color, 1.6, true)
|
||||
|
||||
class MarkerGlyph:
|
||||
extends Control
|
||||
|
||||
var iconName: String = "pin"
|
||||
|
||||
func _draw() -> void:
|
||||
var color := Color(0.258824, 0.627451, 0.913725)
|
||||
var center := size * 0.5
|
||||
match iconName:
|
||||
"anchor":
|
||||
_draw_anchor(center, color)
|
||||
"home":
|
||||
_draw_home(center, color)
|
||||
"whale":
|
||||
_draw_whale(center, color)
|
||||
"tool":
|
||||
_draw_tool(center, color)
|
||||
"notice":
|
||||
_draw_notice(center, color)
|
||||
"gate":
|
||||
_draw_gate(center, color)
|
||||
_:
|
||||
draw_circle(center, 5.0, color)
|
||||
|
||||
func _draw_anchor(center: Vector2, color: Color) -> void:
|
||||
draw_line(center + Vector2(0, -7), center + Vector2(0, 7), color, 2.0, true)
|
||||
draw_circle(center + Vector2(0, -7), 2.8, color)
|
||||
draw_arc(center + Vector2(0, 2), 7.0, 0.20, PI - 0.20, 24, color, 2.0, true)
|
||||
draw_line(center + Vector2(-7, 3), center + Vector2(-10, 0), color, 2.0, true)
|
||||
draw_line(center + Vector2(7, 3), center + Vector2(10, 0), color, 2.0, true)
|
||||
|
||||
func _draw_home(center: Vector2, color: Color) -> void:
|
||||
draw_polyline(PackedVector2Array([
|
||||
center + Vector2(-8, -1), center + Vector2(0, -8), center + Vector2(8, -1)
|
||||
]), color, 2.0, true)
|
||||
draw_rect(Rect2(center + Vector2(-6, -1), Vector2(12, 10)), color, false, 2.0)
|
||||
draw_line(center + Vector2(-1, 9), center + Vector2(-1, 3), color, 2.0, true)
|
||||
|
||||
func _draw_whale(center: Vector2, color: Color) -> void:
|
||||
draw_arc(center + Vector2(-1, 2), 8.0, PI, TAU, 28, color, 2.0, true)
|
||||
draw_line(center + Vector2(6, 0), center + Vector2(11, -4), color, 2.0, true)
|
||||
draw_line(center + Vector2(6, 0), center + Vector2(11, 4), color, 2.0, true)
|
||||
draw_circle(center + Vector2(-4, 0), 1.2, color)
|
||||
|
||||
func _draw_tool(center: Vector2, color: Color) -> void:
|
||||
draw_line(center + Vector2(-7, 7), center + Vector2(7, -7), color, 2.4, true)
|
||||
draw_arc(center + Vector2(7, -7), 5.0, PI * 0.1, PI * 1.3, 18, color, 2.0, true)
|
||||
draw_circle(center + Vector2(-7, 7), 2.4, color)
|
||||
|
||||
func _draw_notice(center: Vector2, color: Color) -> void:
|
||||
draw_rect(Rect2(center + Vector2(-8, -7), Vector2(16, 13)), color, false, 2.0)
|
||||
draw_line(center + Vector2(-5, -2), center + Vector2(5, -2), color, 1.7, true)
|
||||
draw_line(center + Vector2(-5, 2), center + Vector2(2, 2), color, 1.7, true)
|
||||
|
||||
func _draw_gate(center: Vector2, color: Color) -> void:
|
||||
draw_rect(Rect2(center + Vector2(-9, -3), Vector2(18, 11)), color, false, 2.0)
|
||||
draw_line(center + Vector2(0, -3), center + Vector2(0, 8), color, 1.7, true)
|
||||
draw_line(center + Vector2(-11, 8), center + Vector2(11, 8), color, 2.0, true)
|
||||
1
scenes/ui/MapPanel.gd.uid
Normal file
1
scenes/ui/MapPanel.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ckpyd48v7l4w8
|
||||
13
scenes/ui/MapPanel.tscn
Normal file
13
scenes/ui/MapPanel.tscn
Normal file
@@ -0,0 +1,13 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/ui/MapPanel.gd" id="1"]
|
||||
|
||||
[node name="MapPanel" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1")
|
||||
129
scenes/ui/NoticeDialog.gd
Normal file
129
scenes/ui/NoticeDialog.gd
Normal file
@@ -0,0 +1,129 @@
|
||||
extends CanvasLayer
|
||||
class_name NoticeDialog
|
||||
|
||||
const DEFAULT_PAGES: Array[Dictionary] = [
|
||||
{
|
||||
"text": "[center][color=#2f6b73]小镇新闻[/color][/center]\n\n线上新闻接口接入后,这里会同步 Datawhale Town 的最新动态、活动公告和版本消息。\n\n当前请先通过欢迎板查看新人引导手册。",
|
||||
"image_path": "res://assets/maps/square/v1/props/center_whale_fountain_v2_hd_clean.png",
|
||||
},
|
||||
]
|
||||
|
||||
@onready var contentLabel: RichTextLabel = $CenterContainer/PanelContainer/VBoxContainer/ContentContainer/TextPanel/ContentLabel
|
||||
@onready var prevButton: Button = $CenterContainer/PanelContainer/VBoxContainer/Footer/PrevButton
|
||||
@onready var nextButton: Button = $CenterContainer/PanelContainer/VBoxContainer/Footer/NextButton
|
||||
@onready var dotsContainer: HBoxContainer = $CenterContainer/PanelContainer/VBoxContainer/Footer/DotsContainer
|
||||
@onready var contentContainer: Container = $CenterContainer/PanelContainer/VBoxContainer/ContentContainer
|
||||
@onready var imageRect: TextureRect = $CenterContainer/PanelContainer/VBoxContainer/ContentContainer/ImagePanel/ImageRect
|
||||
@onready var imageLabel: Label = $CenterContainer/PanelContainer/VBoxContainer/ContentContainer/ImagePanel/ImageLabel
|
||||
|
||||
var pages: Array[Dictionary] = DEFAULT_PAGES.duplicate(true)
|
||||
var currentPage: int = 0
|
||||
var tween: Tween
|
||||
var _chatUi: Control
|
||||
var _chatUiPrevMouseFilter: Control.MouseFilter = Control.MOUSE_FILTER_STOP
|
||||
var _chatUiMouseDisabled: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
get_tree().paused = true
|
||||
_disableChatUiMouseInput()
|
||||
|
||||
var closeButton := $CenterContainer/PanelContainer/VBoxContainer/Header/RightContainer/CloseButton as Button
|
||||
closeButton.pressed.connect(_onClosePressed)
|
||||
prevButton.pressed.connect(_onPrevPressed)
|
||||
nextButton.pressed.connect(_onNextPressed)
|
||||
|
||||
contentContainer.modulate.a = 1.0
|
||||
_setupDots()
|
||||
_updateUi(false)
|
||||
|
||||
func _exit_tree() -> void:
|
||||
_restoreChatUiMouseInput()
|
||||
|
||||
func _setupDots() -> void:
|
||||
for child in dotsContainer.get_children():
|
||||
child.queue_free()
|
||||
|
||||
for index in range(pages.size()):
|
||||
var dot := ColorRect.new()
|
||||
dot.custom_minimum_size = Vector2(12, 12)
|
||||
dotsContainer.add_child(dot)
|
||||
|
||||
func _updateUi(animate: bool = true) -> void:
|
||||
if pages.is_empty():
|
||||
return
|
||||
|
||||
prevButton.disabled = currentPage == 0
|
||||
nextButton.disabled = currentPage == pages.size() - 1
|
||||
|
||||
var dots := dotsContainer.get_children()
|
||||
for index in range(dots.size()):
|
||||
var dot := dots[index] as ColorRect
|
||||
if dot == null:
|
||||
continue
|
||||
if index == currentPage:
|
||||
dot.color = Color(0.15, 0.36, 0.44, 1.0)
|
||||
dot.custom_minimum_size = Vector2(18, 12)
|
||||
else:
|
||||
dot.color = Color(0.72, 0.78, 0.72, 1.0)
|
||||
dot.custom_minimum_size = Vector2(12, 12)
|
||||
|
||||
if animate:
|
||||
_animateContentChange()
|
||||
else:
|
||||
_setContentImmediate()
|
||||
|
||||
func _setContentImmediate() -> void:
|
||||
var page := pages[currentPage]
|
||||
contentLabel.text = page.get("text", "") as String
|
||||
|
||||
var imagePath := page.get("image_path", "") as String
|
||||
if imagePath != "" and ResourceLoader.exists(imagePath):
|
||||
imageRect.texture = load(imagePath) as Texture2D
|
||||
imageLabel.visible = false
|
||||
else:
|
||||
imageRect.texture = null
|
||||
imageLabel.visible = true
|
||||
imageLabel.text = "暂无图片"
|
||||
|
||||
func _animateContentChange() -> void:
|
||||
if tween != null and tween.is_valid():
|
||||
tween.kill()
|
||||
|
||||
tween = create_tween()
|
||||
tween.tween_property(contentContainer, "modulate:a", 0.0, 0.15)
|
||||
tween.tween_callback(_setContentImmediate)
|
||||
tween.tween_property(contentContainer, "modulate:a", 1.0, 0.15)
|
||||
|
||||
func _onPrevPressed() -> void:
|
||||
if currentPage > 0:
|
||||
currentPage -= 1
|
||||
_updateUi()
|
||||
|
||||
func _onNextPressed() -> void:
|
||||
if currentPage < pages.size() - 1:
|
||||
currentPage += 1
|
||||
_updateUi()
|
||||
|
||||
func _onClosePressed() -> void:
|
||||
get_tree().paused = false
|
||||
queue_free()
|
||||
|
||||
func _disableChatUiMouseInput() -> void:
|
||||
var currentScene := get_tree().current_scene
|
||||
if currentScene != null:
|
||||
_chatUi = currentScene.get_node_or_null("UILayer/ChatUI") as Control
|
||||
|
||||
if _chatUi == null:
|
||||
_chatUi = get_tree().root.find_child("ChatUI", true, false) as Control
|
||||
|
||||
if _chatUi != null:
|
||||
_chatUiPrevMouseFilter = _chatUi.mouse_filter
|
||||
_chatUi.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_chatUiMouseDisabled = true
|
||||
|
||||
func _restoreChatUiMouseInput() -> void:
|
||||
if _chatUiMouseDisabled and is_instance_valid(_chatUi):
|
||||
_chatUi.mouse_filter = _chatUiPrevMouseFilter
|
||||
|
||||
_chatUiMouseDisabled = false
|
||||
_chatUi = null
|
||||
1
scenes/ui/NoticeDialog.gd.uid
Normal file
1
scenes/ui/NoticeDialog.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cd8ueskjdp4g5
|
||||
489
scenes/ui/PlayerHud.gd
Normal file
489
scenes/ui/PlayerHud.gd
Normal file
@@ -0,0 +1,489 @@
|
||||
extends Control
|
||||
|
||||
# ============================================================================
|
||||
# PlayerHud.gd - 右上角玩家头像与快捷入口
|
||||
# ============================================================================
|
||||
# 展示当前玩家头像入口,以及地图/任务/背包/好友/设置入口。
|
||||
# 好友入口只负责打开或收起右下角好友列表。
|
||||
# ============================================================================
|
||||
|
||||
const HUD_MARGIN: Vector2 = Vector2(16, 16)
|
||||
const SHORTCUT_BAR_SIZE: Vector2 = Vector2(350, 86)
|
||||
const PLAYER_PROFILE_BUTTON_SIZE: Vector2 = Vector2(210, 78)
|
||||
const PLAYER_AVATAR_SIZE: Vector2 = Vector2(58, 58)
|
||||
const HUD_SEPARATION: int = 18
|
||||
const HUD_TOTAL_WIDTH: float = SHORTCUT_BAR_SIZE.x + PLAYER_PROFILE_BUTTON_SIZE.x + HUD_SEPARATION
|
||||
const TEXT_COLOR: Color = Color(0.188, 0.294, 0.424)
|
||||
const MUTED_COLOR: Color = Color(0.560, 0.639, 0.733)
|
||||
const ACCENT_COLOR: Color = Color(0.258824, 0.627451, 0.913725)
|
||||
const WALLET_REFRESH_INTERVAL: float = 15.0
|
||||
const HUD_SHORTCUT_ICON_SCRIPT: Script = preload("res://scenes/ui/HudShortcutIcon.gd")
|
||||
|
||||
var _usernameLabel: Label
|
||||
var _walletLabel: Label
|
||||
var _avatarLabel: Label
|
||||
var _avatarPanel: PanelContainer
|
||||
var _walletRefreshTimer: Timer
|
||||
|
||||
var _currentUsername: String = "玩家"
|
||||
var _walletBalance: int = 0
|
||||
var _walletLoaded: bool = false
|
||||
var _walletLoading: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_build_ui()
|
||||
_build_wallet_refresh_timer()
|
||||
_subscribe_to_events()
|
||||
_load_current_user()
|
||||
_refresh_wallet()
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var eventSystem := _get_event_system()
|
||||
if eventSystem != null:
|
||||
eventSystem.call("disconnect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_chat_login_success, self)
|
||||
eventSystem.call("disconnect_event", EventNames.AUTH_LOGIN_SUCCESS, _on_auth_login_success, self)
|
||||
eventSystem.call("disconnect_event", EventNames.AUTH_REGISTER_SUCCESS, _on_auth_register_success, self)
|
||||
eventSystem.call("disconnect_event", EventNames.AUTH_LOGOUT, _on_auth_logout, self)
|
||||
eventSystem.call("disconnect_event", EventNames.APPEARANCE_AVATAR_CHANGED, _on_appearance_avatar_changed, self)
|
||||
eventSystem.call("disconnect_event", EventNames.MALL_PURCHASE_SUCCEEDED, _on_wallet_mutation_event, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_CHAT_TIME_PURCHASED, _on_wallet_mutation_event, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_EMPLOYMENT_RESIGNED, _on_wallet_mutation_event, self)
|
||||
var playerStateManager := get_node_or_null("/root/PlayerStateManager")
|
||||
if playerStateManager != null and playerStateManager.has_signal("wallet_changed"):
|
||||
var walletCallback := Callable(self, "_on_player_wallet_changed")
|
||||
if playerStateManager.is_connected("wallet_changed", walletCallback):
|
||||
playerStateManager.disconnect("wallet_changed", walletCallback)
|
||||
var authManager := get_node_or_null("/root/AuthManager")
|
||||
if authManager != null and authManager.has_signal("auth_state_changed"):
|
||||
var callback := Callable(self, "_on_auth_state_changed")
|
||||
if authManager.is_connected("auth_state_changed", callback):
|
||||
authManager.disconnect("auth_state_changed", callback)
|
||||
|
||||
func _build_ui() -> void:
|
||||
var rootRow := HBoxContainer.new()
|
||||
rootRow.name = "HudRoot"
|
||||
rootRow.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||
rootRow.offset_left = -HUD_TOTAL_WIDTH - HUD_MARGIN.x
|
||||
rootRow.offset_top = HUD_MARGIN.y
|
||||
rootRow.offset_right = -HUD_MARGIN.x
|
||||
rootRow.add_theme_constant_override("separation", HUD_SEPARATION)
|
||||
add_child(rootRow)
|
||||
|
||||
rootRow.add_child(_build_shortcut_bar())
|
||||
rootRow.add_child(_build_player_avatar_button())
|
||||
|
||||
func _build_shortcut_bar() -> PanelContainer:
|
||||
var panel := PanelContainer.new()
|
||||
panel.custom_minimum_size = SHORTCUT_BAR_SIZE
|
||||
panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
panel.add_theme_stylebox_override("panel", _create_panel_style(32))
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 18)
|
||||
margin.add_theme_constant_override("margin_top", 12)
|
||||
margin.add_theme_constant_override("margin_right", 18)
|
||||
margin.add_theme_constant_override("margin_bottom", 12)
|
||||
panel.add_child(margin)
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
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))
|
||||
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("settings", "设置", _on_settings_pressed))
|
||||
|
||||
return panel
|
||||
|
||||
func _build_player_avatar_button() -> Button:
|
||||
var button := Button.new()
|
||||
button.custom_minimum_size = PLAYER_PROFILE_BUTTON_SIZE
|
||||
button.tooltip_text = "角色设置"
|
||||
button.focus_mode = Control.FOCUS_NONE
|
||||
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
button.text = ""
|
||||
button.add_theme_stylebox_override("normal", _create_avatar_button_style(Color(1, 1, 1, 0.94), 39))
|
||||
button.add_theme_stylebox_override("hover", _create_avatar_button_style(Color(0.918, 0.961, 0.992, 0.98), 39))
|
||||
button.add_theme_stylebox_override("pressed", _create_avatar_button_style(Color(0.858, 0.925, 0.980, 1.0), 39))
|
||||
button.add_theme_stylebox_override("focus", _create_empty_style())
|
||||
button.pressed.connect(_on_avatar_pressed)
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
margin.add_theme_constant_override("margin_left", 10)
|
||||
margin.add_theme_constant_override("margin_top", 10)
|
||||
margin.add_theme_constant_override("margin_right", 14)
|
||||
margin.add_theme_constant_override("margin_bottom", 10)
|
||||
button.add_child(margin)
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
row.add_theme_constant_override("separation", 10)
|
||||
margin.add_child(row)
|
||||
|
||||
var avatar := PanelContainer.new()
|
||||
_avatarPanel = avatar
|
||||
avatar.custom_minimum_size = PLAYER_AVATAR_SIZE
|
||||
avatar.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
avatar.clip_contents = true
|
||||
row.add_child(avatar)
|
||||
|
||||
_avatarLabel = Label.new()
|
||||
_avatarLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_avatarLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_avatarLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_avatarLabel.add_theme_color_override("font_color", Color.WHITE)
|
||||
_avatarLabel.add_theme_font_size_override("font_size", 22)
|
||||
avatar.add_child(_avatarLabel)
|
||||
_apply_current_avatar()
|
||||
|
||||
_usernameLabel = Label.new()
|
||||
_usernameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_usernameLabel.text = _currentUsername
|
||||
_usernameLabel.custom_minimum_size = Vector2(96, 0)
|
||||
_usernameLabel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_usernameLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_usernameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
_usernameLabel.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
_usernameLabel.add_theme_font_size_override("font_size", 18)
|
||||
|
||||
var info := VBoxContainer.new()
|
||||
info.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
info.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
info.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
info.add_theme_constant_override("separation", 2)
|
||||
row.add_child(info)
|
||||
info.add_child(_usernameLabel)
|
||||
|
||||
_walletLabel = Label.new()
|
||||
_walletLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_walletLabel.text = "鲸币 --"
|
||||
_walletLabel.custom_minimum_size = Vector2(112, 0)
|
||||
_walletLabel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_walletLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
_walletLabel.add_theme_color_override("font_color", ACCENT_COLOR)
|
||||
_walletLabel.add_theme_font_size_override("font_size", 14)
|
||||
info.add_child(_walletLabel)
|
||||
|
||||
return button
|
||||
|
||||
func _create_shortcut_button(iconName: String, text: String, callback: Callable) -> Button:
|
||||
var button := Button.new()
|
||||
button.custom_minimum_size = Vector2(56, 60)
|
||||
button.focus_mode = Control.FOCUS_NONE
|
||||
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
button.text = ""
|
||||
button.add_theme_stylebox_override("normal", _create_empty_style())
|
||||
button.add_theme_stylebox_override("hover", _create_pill_style(Color(0.918, 0.961, 0.992, 0.86), 18))
|
||||
button.add_theme_stylebox_override("pressed", _create_pill_style(Color(0.858, 0.925, 0.980, 0.98), 18))
|
||||
button.add_theme_stylebox_override("focus", _create_empty_style())
|
||||
button.pressed.connect(callback)
|
||||
|
||||
var content := VBoxContainer.new()
|
||||
content.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
content.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
content.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
content.add_theme_constant_override("separation", 1)
|
||||
button.add_child(content)
|
||||
|
||||
var icon := HUD_SHORTCUT_ICON_SCRIPT.new() as Control
|
||||
icon.set("iconName", iconName)
|
||||
icon.custom_minimum_size = Vector2(31, 31)
|
||||
content.add_child(icon)
|
||||
|
||||
var label := Label.new()
|
||||
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
label.text = text
|
||||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
label.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
label.add_theme_font_size_override("font_size", 14)
|
||||
content.add_child(label)
|
||||
|
||||
button.mouse_entered.connect(func() -> void:
|
||||
if is_instance_valid(label):
|
||||
label.add_theme_color_override("font_color", ACCENT_COLOR)
|
||||
)
|
||||
button.mouse_exited.connect(func() -> void:
|
||||
if is_instance_valid(label):
|
||||
label.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
)
|
||||
return button
|
||||
|
||||
func _subscribe_to_events() -> void:
|
||||
var eventSystem := _get_event_system()
|
||||
if eventSystem == null:
|
||||
push_warning("PlayerHud: EventSystem autoload is not available.")
|
||||
return
|
||||
|
||||
eventSystem.call("connect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_chat_login_success, self)
|
||||
eventSystem.call("connect_event", EventNames.AUTH_LOGIN_SUCCESS, _on_auth_login_success, self)
|
||||
eventSystem.call("connect_event", EventNames.AUTH_REGISTER_SUCCESS, _on_auth_register_success, self)
|
||||
eventSystem.call("connect_event", EventNames.AUTH_LOGOUT, _on_auth_logout, self)
|
||||
eventSystem.call("connect_event", EventNames.APPEARANCE_AVATAR_CHANGED, _on_appearance_avatar_changed, self)
|
||||
eventSystem.call("connect_event", EventNames.MALL_PURCHASE_SUCCEEDED, _on_wallet_mutation_event, self)
|
||||
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_CHAT_TIME_PURCHASED, _on_wallet_mutation_event, self)
|
||||
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_EMPLOYMENT_RESIGNED, _on_wallet_mutation_event, self)
|
||||
var playerStateManager := get_node_or_null("/root/PlayerStateManager")
|
||||
if playerStateManager != null and playerStateManager.has_signal("wallet_changed"):
|
||||
var walletCallback := Callable(self, "_on_player_wallet_changed")
|
||||
if not playerStateManager.is_connected("wallet_changed", walletCallback):
|
||||
playerStateManager.connect("wallet_changed", walletCallback)
|
||||
var authManager := get_node_or_null("/root/AuthManager")
|
||||
if authManager != null and authManager.has_signal("auth_state_changed"):
|
||||
var callback := Callable(self, "_on_auth_state_changed")
|
||||
if not authManager.is_connected("auth_state_changed", callback):
|
||||
authManager.connect("auth_state_changed", callback)
|
||||
|
||||
func _load_current_user() -> void:
|
||||
var authManager := get_node_or_null("/root/AuthManager")
|
||||
if authManager != null and authManager.has_method("get_current_username"):
|
||||
var username := str(authManager.call("get_current_username")).strip_edges()
|
||||
if not username.is_empty():
|
||||
_set_username(username)
|
||||
|
||||
func _on_chat_login_success(data: Dictionary) -> void:
|
||||
var username := str(data.get("username", "")).strip_edges()
|
||||
if not username.is_empty():
|
||||
_set_username(username)
|
||||
|
||||
func _on_auth_login_success(data: Dictionary) -> void:
|
||||
_apply_auth_user_payload(data)
|
||||
|
||||
func _on_auth_register_success(data: Dictionary) -> void:
|
||||
_apply_auth_user_payload(data)
|
||||
|
||||
func _on_auth_state_changed(isAuthenticated: bool, user: Dictionary) -> void:
|
||||
if not isAuthenticated:
|
||||
_set_username("玩家")
|
||||
_reset_wallet()
|
||||
return
|
||||
_apply_auth_user_payload({"user": user})
|
||||
_refresh_wallet()
|
||||
|
||||
func _apply_auth_user_payload(data: Dictionary) -> void:
|
||||
var userVariant: Variant = data.get("user", {})
|
||||
if userVariant is Dictionary:
|
||||
var username := str((userVariant as Dictionary).get("username", "")).strip_edges()
|
||||
if not username.is_empty():
|
||||
_set_username(username)
|
||||
|
||||
func _on_auth_logout(_data: Variant = null) -> void:
|
||||
_set_username("玩家")
|
||||
_reset_wallet()
|
||||
|
||||
func _on_appearance_avatar_changed(_data: Dictionary) -> void:
|
||||
_apply_current_avatar()
|
||||
|
||||
func _set_username(username: String) -> void:
|
||||
_currentUsername = username.strip_edges()
|
||||
if _currentUsername.is_empty():
|
||||
_currentUsername = "玩家"
|
||||
if is_instance_valid(_usernameLabel):
|
||||
_usernameLabel.text = _currentUsername
|
||||
_apply_current_avatar()
|
||||
|
||||
func _on_wallet_mutation_event(data: Dictionary) -> void:
|
||||
var balanceVariant: Variant = data.get("balance", null)
|
||||
if balanceVariant != null and (balanceVariant is int or balanceVariant is float):
|
||||
_apply_wallet_balance(int(balanceVariant))
|
||||
return
|
||||
_refresh_wallet()
|
||||
|
||||
func _build_wallet_refresh_timer() -> void:
|
||||
_walletRefreshTimer = Timer.new()
|
||||
_walletRefreshTimer.name = "HudWalletRefreshTimer"
|
||||
_walletRefreshTimer.wait_time = WALLET_REFRESH_INTERVAL
|
||||
_walletRefreshTimer.autostart = true
|
||||
_walletRefreshTimer.timeout.connect(_on_wallet_refresh_timer_timeout)
|
||||
add_child(_walletRefreshTimer)
|
||||
|
||||
func _on_wallet_refresh_timer_timeout() -> void:
|
||||
_refresh_wallet()
|
||||
|
||||
func _refresh_wallet() -> void:
|
||||
if not _is_wallet_available():
|
||||
_reset_wallet()
|
||||
return
|
||||
var playerStateManager := get_node_or_null("/root/PlayerStateManager")
|
||||
if playerStateManager == null:
|
||||
_reset_wallet()
|
||||
return
|
||||
if playerStateManager.has_method("get_wallet"):
|
||||
var walletVariant: Variant = playerStateManager.call("get_wallet")
|
||||
if walletVariant is Dictionary and not (walletVariant as Dictionary).is_empty():
|
||||
_on_player_wallet_changed(walletVariant as Dictionary)
|
||||
return
|
||||
if _walletLoading:
|
||||
return
|
||||
_walletLoading = true
|
||||
_update_wallet_label()
|
||||
if playerStateManager.has_method("refresh_snapshot"):
|
||||
playerStateManager.call("refresh_snapshot")
|
||||
|
||||
func _on_player_wallet_changed(wallet: Dictionary) -> void:
|
||||
if wallet.is_empty():
|
||||
_reset_wallet()
|
||||
return
|
||||
_apply_wallet_balance(int(wallet.get("balance", 0)))
|
||||
|
||||
func _apply_wallet_balance(balance: int) -> void:
|
||||
_walletBalance = max(0, balance)
|
||||
_walletLoaded = true
|
||||
_walletLoading = false
|
||||
_update_wallet_label()
|
||||
|
||||
func _reset_wallet() -> void:
|
||||
_walletBalance = 0
|
||||
_walletLoaded = false
|
||||
_walletLoading = false
|
||||
_update_wallet_label()
|
||||
|
||||
func _update_wallet_label(overrideText: String = "") -> void:
|
||||
if not is_instance_valid(_walletLabel):
|
||||
return
|
||||
if not overrideText.is_empty():
|
||||
_walletLabel.text = "鲸币 %s" % overrideText
|
||||
_walletLabel.tooltip_text = "鲸币余额"
|
||||
return
|
||||
if _walletLoading:
|
||||
_walletLabel.text = "鲸币 读取中"
|
||||
elif _walletLoaded:
|
||||
_walletLabel.text = "鲸币 %s" % _format_number(_walletBalance)
|
||||
else:
|
||||
_walletLabel.text = "鲸币 --"
|
||||
_walletLabel.tooltip_text = "鲸币余额"
|
||||
|
||||
func _format_number(value: int) -> String:
|
||||
var text := str(value)
|
||||
var result := ""
|
||||
var count := 0
|
||||
for i in range(text.length() - 1, -1, -1):
|
||||
if count > 0 and count % 3 == 0:
|
||||
result = "," + result
|
||||
result = text[i] + result
|
||||
count += 1
|
||||
return result
|
||||
|
||||
func _is_wallet_available() -> bool:
|
||||
var authManager := get_node_or_null("/root/AuthManager")
|
||||
return authManager != null and authManager.has_method("is_authenticated") and bool(authManager.call("is_authenticated")) and authManager.has_method("get_access_token")
|
||||
|
||||
func _on_map_pressed() -> void:
|
||||
var eventSystem := _get_event_system()
|
||||
if eventSystem != null:
|
||||
eventSystem.call("emit_event", EventNames.HUD_MAP_TOGGLE, {})
|
||||
|
||||
func _on_task_pressed() -> void:
|
||||
_emit_status_message("任务入口稍后接入")
|
||||
|
||||
func _on_backpack_pressed() -> void:
|
||||
var eventSystem := _get_event_system()
|
||||
if eventSystem != null:
|
||||
eventSystem.call("emit_event", EventNames.HUD_BACKPACK_TOGGLE, {})
|
||||
|
||||
func _on_friends_pressed() -> void:
|
||||
var eventSystem := _get_event_system()
|
||||
if eventSystem != null:
|
||||
eventSystem.call("emit_event", EventNames.HUD_FRIEND_LIST_TOGGLE, {})
|
||||
|
||||
func _on_settings_pressed() -> void:
|
||||
var eventSystem := _get_event_system()
|
||||
if eventSystem != null:
|
||||
eventSystem.call("emit_event", EventNames.HUD_SETTINGS_REQUESTED, {})
|
||||
|
||||
func _on_avatar_pressed() -> void:
|
||||
var eventSystem := _get_event_system()
|
||||
if eventSystem != null:
|
||||
eventSystem.call("emit_event", EventNames.HUD_SETTINGS_REQUESTED, {})
|
||||
|
||||
func _emit_status_message(message: String) -> void:
|
||||
var eventSystem := _get_event_system()
|
||||
if eventSystem != null:
|
||||
eventSystem.call("emit_event", EventNames.CHAT_MESSAGE_RECEIVED, {
|
||||
"from_user": "系统",
|
||||
"content": message,
|
||||
"timestamp": Time.get_unix_time_from_system(),
|
||||
"is_self": false,
|
||||
"scope": "system",
|
||||
"tab": "world"
|
||||
})
|
||||
|
||||
func _create_panel_style(radius: int) -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(1, 1, 1, 0.925)
|
||||
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.12549, 0.282353, 0.407843, 0.15)
|
||||
style.shadow_size = 18
|
||||
style.shadow_offset = Vector2(0, 7)
|
||||
return style
|
||||
|
||||
func _create_avatar_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.361, 0.690, 0.914)
|
||||
style.corner_radius_top_left = 32
|
||||
style.corner_radius_top_right = 32
|
||||
style.corner_radius_bottom_left = 32
|
||||
style.corner_radius_bottom_right = 32
|
||||
return style
|
||||
|
||||
func _apply_current_avatar() -> void:
|
||||
if not is_instance_valid(_avatarPanel) or not is_instance_valid(_avatarLabel):
|
||||
return
|
||||
var appearanceManager := get_node_or_null("/root/AppearanceManager")
|
||||
if appearanceManager != null and appearanceManager.has_method("apply_avatar_to_panel"):
|
||||
appearanceManager.call("apply_avatar_to_panel", _avatarPanel, _avatarLabel, "", _currentUsername)
|
||||
_avatarPanel.add_theme_stylebox_override("panel", _create_round_avatar_style(_avatarPanel))
|
||||
return
|
||||
_avatarPanel.add_theme_stylebox_override("panel", _create_avatar_style())
|
||||
_avatarLabel.visible = true
|
||||
_avatarLabel.text = _currentUsername.substr(0, 1).to_upper()
|
||||
|
||||
func _create_avatar_button_style(color: Color, radius: int) -> StyleBoxFlat:
|
||||
var style := _create_pill_style(color, radius)
|
||||
style.shadow_color = Color(0.12549, 0.282353, 0.407843, 0.14)
|
||||
style.shadow_size = 16
|
||||
style.shadow_offset = Vector2(0, 6)
|
||||
return style
|
||||
|
||||
func _create_round_avatar_style(panel: PanelContainer) -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.361, 0.690, 0.914)
|
||||
var radius := int(round(minf(panel.custom_minimum_size.x, panel.custom_minimum_size.y) * 0.5))
|
||||
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.border_width_left = 2
|
||||
style.border_width_top = 2
|
||||
style.border_width_right = 2
|
||||
style.border_width_bottom = 2
|
||||
style.border_color = Color(1, 1, 1, 0.78)
|
||||
return style
|
||||
|
||||
func _create_pill_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.content_margin_left = 8
|
||||
style.content_margin_right = 8
|
||||
style.content_margin_top = 5
|
||||
style.content_margin_bottom = 5
|
||||
return style
|
||||
|
||||
func _create_empty_style() -> StyleBoxEmpty:
|
||||
return StyleBoxEmpty.new()
|
||||
|
||||
func _get_event_system() -> Node:
|
||||
return get_node_or_null("/root/EventSystem")
|
||||
1
scenes/ui/PlayerHud.gd.uid
Normal file
1
scenes/ui/PlayerHud.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cgq6k8oxurhrr
|
||||
13
scenes/ui/PlayerHud.tscn
Normal file
13
scenes/ui/PlayerHud.tscn
Normal file
@@ -0,0 +1,13 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/ui/PlayerHud.gd" id="1"]
|
||||
|
||||
[node name="PlayerHud" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1")
|
||||
1315
scenes/ui/SettingsPanel.gd
Normal file
1315
scenes/ui/SettingsPanel.gd
Normal file
File diff suppressed because it is too large
Load Diff
1
scenes/ui/SettingsPanel.gd.uid
Normal file
1
scenes/ui/SettingsPanel.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://3vltb77vwxy
|
||||
13
scenes/ui/SettingsPanel.tscn
Normal file
13
scenes/ui/SettingsPanel.tscn
Normal file
@@ -0,0 +1,13 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/ui/SettingsPanel.gd" id="1_settings"]
|
||||
|
||||
[node name="SettingsPanel" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_settings")
|
||||
208
scenes/ui/SettingsPanelIcon.gd
Normal file
208
scenes/ui/SettingsPanelIcon.gd
Normal file
@@ -0,0 +1,208 @@
|
||||
extends Control
|
||||
|
||||
# ============================================================================
|
||||
# SettingsPanelIcon.gd - 设置面板细线图标
|
||||
# ============================================================================
|
||||
# 用代码绘制设置面板内的小图标,保持和右上角 HUD 一致的轻线条风格。
|
||||
# ============================================================================
|
||||
|
||||
const BASE_SIZE: float = 32.0
|
||||
const INACTIVE_COLOR: Color = Color(0.356, 0.525, 0.690, 0.86)
|
||||
const ACTIVE_COLOR: Color = Color(0.259, 0.627, 0.914, 1.0)
|
||||
const MUTED_COLOR: Color = Color(0.650, 0.745, 0.827, 0.72)
|
||||
const LINE_WIDTH: float = 1.25
|
||||
const DETAIL_WIDTH: float = 1.05
|
||||
|
||||
@export var iconName: String = "basic"
|
||||
@export var active: bool = false
|
||||
|
||||
var _iconScale: float = 1.0
|
||||
var _iconOffset: Vector2 = Vector2.ZERO
|
||||
|
||||
func _ready() -> void:
|
||||
custom_minimum_size = Vector2(32, 32)
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
func set_active(value: bool) -> void:
|
||||
active = value
|
||||
queue_redraw()
|
||||
|
||||
func _draw() -> void:
|
||||
_iconScale = min(size.x, size.y) / BASE_SIZE
|
||||
_iconOffset = (size - Vector2(BASE_SIZE, BASE_SIZE) * _iconScale) * 0.5
|
||||
|
||||
match iconName:
|
||||
"basic":
|
||||
_draw_basic()
|
||||
"audio":
|
||||
_draw_audio()
|
||||
"chat":
|
||||
_draw_chat()
|
||||
"controls":
|
||||
_draw_controls()
|
||||
"account":
|
||||
_draw_account()
|
||||
"volume":
|
||||
_draw_volume()
|
||||
"music":
|
||||
_draw_music()
|
||||
"effects":
|
||||
_draw_effects()
|
||||
"window":
|
||||
_draw_window()
|
||||
"eye":
|
||||
_draw_eye()
|
||||
"keyboard":
|
||||
_draw_keyboard()
|
||||
"whale":
|
||||
_draw_whale()
|
||||
_:
|
||||
_draw_basic()
|
||||
|
||||
func _draw_basic() -> void:
|
||||
_draw_polyline([
|
||||
Vector2(7.0, 17.0),
|
||||
Vector2(16.0, 8.6),
|
||||
Vector2(25.0, 17.0)
|
||||
])
|
||||
_draw_round_rect(Rect2(9.6, 16.3, 12.8, 9.0), 1.8)
|
||||
_draw_line(Vector2(16.0, 20.4), Vector2(16.0, 25.0), DETAIL_WIDTH)
|
||||
|
||||
func _draw_audio() -> void:
|
||||
_draw_music()
|
||||
_draw_line(Vector2(21.0, 8.0), Vector2(24.4, 11.4), DETAIL_WIDTH)
|
||||
_draw_line(Vector2(24.4, 11.4), Vector2(21.0, 14.8), DETAIL_WIDTH)
|
||||
|
||||
func _draw_chat() -> void:
|
||||
_draw_round_rect(Rect2(6.5, 8.5, 19.0, 14.0), 4.0)
|
||||
_draw_polyline([
|
||||
Vector2(12.0, 22.0),
|
||||
Vector2(10.0, 26.0),
|
||||
Vector2(16.0, 22.5)
|
||||
], DETAIL_WIDTH)
|
||||
_draw_line(Vector2(11.0, 14.2), Vector2(21.0, 14.2), DETAIL_WIDTH)
|
||||
_draw_line(Vector2(11.0, 18.0), Vector2(18.4, 18.0), DETAIL_WIDTH)
|
||||
|
||||
func _draw_controls() -> void:
|
||||
_draw_round_rect(Rect2(5.7, 12.0, 20.6, 10.8), 4.8)
|
||||
_draw_line(Vector2(10.5, 15.2), Vector2(10.5, 19.4), DETAIL_WIDTH)
|
||||
_draw_line(Vector2(8.4, 17.3), Vector2(12.6, 17.3), DETAIL_WIDTH)
|
||||
draw_circle(_p(Vector2(19.6, 16.0)), 1.15 * _iconScale, _color())
|
||||
draw_circle(_p(Vector2(22.0, 19.0)), 1.15 * _iconScale, _color())
|
||||
|
||||
func _draw_account() -> void:
|
||||
_draw_arc(Vector2(16.0, 12.0), 4.4, 0.0, TAU, 32, LINE_WIDTH)
|
||||
_draw_arc_poly(Vector2(16.0, 28.5), 10.2, PI + 0.20, TAU - 0.20, 28)
|
||||
|
||||
func _draw_volume() -> void:
|
||||
_draw_polyline([
|
||||
Vector2(6.5, 14.0),
|
||||
Vector2(11.0, 14.0),
|
||||
Vector2(17.2, 9.0),
|
||||
Vector2(17.2, 23.0),
|
||||
Vector2(11.0, 18.0),
|
||||
Vector2(6.5, 18.0),
|
||||
Vector2(6.5, 14.0)
|
||||
])
|
||||
_draw_arc(Vector2(18.0, 16.0), 5.6, -0.62, 0.62, 14, DETAIL_WIDTH)
|
||||
_draw_arc(Vector2(18.0, 16.0), 8.8, -0.56, 0.56, 16, DETAIL_WIDTH)
|
||||
|
||||
func _draw_music() -> void:
|
||||
_draw_line(Vector2(12.0, 9.0), Vector2(12.0, 22.0), LINE_WIDTH)
|
||||
_draw_line(Vector2(22.0, 7.0), Vector2(22.0, 20.2), LINE_WIDTH)
|
||||
_draw_line(Vector2(12.0, 9.0), Vector2(22.0, 7.0), LINE_WIDTH)
|
||||
_draw_line(Vector2(12.0, 12.5), Vector2(22.0, 10.5), DETAIL_WIDTH)
|
||||
_draw_arc(Vector2(9.6, 23.0), 3.0, 0.0, TAU, 24, LINE_WIDTH)
|
||||
_draw_arc(Vector2(19.6, 21.0), 3.0, 0.0, TAU, 24, LINE_WIDTH)
|
||||
|
||||
func _draw_effects() -> void:
|
||||
_draw_polyline([
|
||||
Vector2(16.0, 6.5),
|
||||
Vector2(18.4, 13.2),
|
||||
Vector2(25.5, 13.4),
|
||||
Vector2(19.8, 17.7),
|
||||
Vector2(21.8, 24.6),
|
||||
Vector2(16.0, 20.4),
|
||||
Vector2(10.2, 24.6),
|
||||
Vector2(12.2, 17.7),
|
||||
Vector2(6.5, 13.4),
|
||||
Vector2(13.6, 13.2),
|
||||
Vector2(16.0, 6.5)
|
||||
])
|
||||
|
||||
func _draw_window() -> void:
|
||||
_draw_round_rect(Rect2(6.0, 8.0, 20.0, 16.0), 2.3)
|
||||
_draw_line(Vector2(6.6, 12.2), Vector2(25.4, 12.2), DETAIL_WIDTH)
|
||||
_draw_line(Vector2(10.0, 16.2), Vector2(16.0, 16.2), DETAIL_WIDTH)
|
||||
_draw_line(Vector2(10.0, 19.6), Vector2(20.8, 19.6), DETAIL_WIDTH)
|
||||
|
||||
func _draw_eye() -> void:
|
||||
_draw_arc_poly(Vector2(16.0, 17.0), 10.2, PI + 0.34, TAU - 0.34, 28)
|
||||
_draw_arc_poly(Vector2(16.0, 10.8), 10.2, 0.34, PI - 0.34, 28)
|
||||
_draw_arc(Vector2(16.0, 14.0), 2.8, 0.0, TAU, 24, DETAIL_WIDTH)
|
||||
|
||||
func _draw_keyboard() -> void:
|
||||
_draw_round_rect(Rect2(5.5, 9.0, 21.0, 14.0), 2.5)
|
||||
for y in [13.0, 17.0]:
|
||||
_draw_line(Vector2(9.0, y), Vector2(23.0, y), DETAIL_WIDTH)
|
||||
_draw_line(Vector2(11.0, 20.3), Vector2(21.0, 20.3), DETAIL_WIDTH)
|
||||
|
||||
func _draw_whale() -> void:
|
||||
var bodyColor := Color(0.424, 0.713, 0.918, 1.0)
|
||||
var finColor := Color(0.306, 0.612, 0.847, 1.0)
|
||||
var white := Color(1.0, 1.0, 1.0, 0.95)
|
||||
draw_arc(_p(Vector2(16.0, 18.0)), 9.0 * _iconScale, PI * 0.04, PI * 1.08, 40, bodyColor, 5.5 * _iconScale, true)
|
||||
draw_circle(_p(Vector2(16.0, 18.0)), 6.8 * _iconScale, bodyColor)
|
||||
draw_circle(_p(Vector2(18.2, 15.4)), 0.9 * _iconScale, Color(0.160, 0.286, 0.420, 0.9))
|
||||
_draw_line_colored(Vector2(8.3, 18.2), Vector2(4.5, 14.8), finColor, 1.8)
|
||||
_draw_line_colored(Vector2(8.4, 18.4), Vector2(4.6, 21.4), finColor, 1.8)
|
||||
_draw_arc_colored(Vector2(15.0, 8.4), 4.0, PI * 1.05, PI * 1.80, 14, finColor, 1.5)
|
||||
_draw_arc_colored(Vector2(18.0, 8.4), 4.0, PI * 1.20, PI * 1.95, 14, finColor, 1.5)
|
||||
draw_circle(_p(Vector2(20.8, 22.0)), 1.1 * _iconScale, white)
|
||||
draw_circle(_p(Vector2(12.7, 22.2)), 1.0 * _iconScale, white)
|
||||
|
||||
func _draw_round_rect(rect: Rect2, radius: float, width: float = LINE_WIDTH) -> void:
|
||||
var left := rect.position.x
|
||||
var top := rect.position.y
|
||||
var right := rect.end.x
|
||||
var bottom := rect.end.y
|
||||
_draw_line(Vector2(left + radius, top), Vector2(right - radius, top), width)
|
||||
_draw_line(Vector2(right, top + radius), Vector2(right, bottom - radius), width)
|
||||
_draw_line(Vector2(right - radius, bottom), Vector2(left + radius, bottom), width)
|
||||
_draw_line(Vector2(left, bottom - radius), Vector2(left, top + radius), width)
|
||||
_draw_arc(Vector2(left + radius, top + radius), radius, PI, PI * 1.5, 8, width)
|
||||
_draw_arc(Vector2(right - radius, top + radius), radius, PI * 1.5, TAU, 8, width)
|
||||
_draw_arc(Vector2(right - radius, bottom - radius), radius, 0.0, PI * 0.5, 8, width)
|
||||
_draw_arc(Vector2(left + radius, bottom - radius), radius, PI * 0.5, PI, 8, width)
|
||||
|
||||
func _draw_arc_poly(center: Vector2, radius: float, start: float, end: float, pointCount: int, width: float = LINE_WIDTH) -> void:
|
||||
var points: PackedVector2Array = []
|
||||
for i in range(pointCount + 1):
|
||||
var t := float(i) / float(pointCount)
|
||||
var angle := lerpf(start, end, t)
|
||||
points.append(_p(center + Vector2(cos(angle), sin(angle)) * radius))
|
||||
draw_polyline(points, _color(), width * _iconScale, true)
|
||||
|
||||
func _draw_polyline(points: Array[Vector2], width: float = LINE_WIDTH) -> void:
|
||||
var packed: PackedVector2Array = []
|
||||
for point in points:
|
||||
packed.append(_p(point))
|
||||
draw_polyline(packed, _color(), width * _iconScale, true)
|
||||
|
||||
func _draw_line(from: Vector2, to: Vector2, width: float) -> void:
|
||||
draw_line(_p(from), _p(to), _color(), width * _iconScale, true)
|
||||
|
||||
func _draw_line_colored(from: Vector2, to: Vector2, color: Color, width: float) -> void:
|
||||
draw_line(_p(from), _p(to), color, width * _iconScale, true)
|
||||
|
||||
func _draw_arc(center: Vector2, radius: float, startAngle: float, endAngle: float, pointCount: int, width: float) -> void:
|
||||
draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, _color(), width * _iconScale, true)
|
||||
|
||||
func _draw_arc_colored(center: Vector2, radius: float, startAngle: float, endAngle: float, pointCount: int, color: Color, width: float) -> void:
|
||||
draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, color, width * _iconScale, true)
|
||||
|
||||
func _p(point: Vector2) -> Vector2:
|
||||
return _iconOffset + point * _iconScale
|
||||
|
||||
func _color() -> Color:
|
||||
return ACTIVE_COLOR if active else INACTIVE_COLOR
|
||||
1
scenes/ui/SettingsPanelIcon.gd.uid
Normal file
1
scenes/ui/SettingsPanelIcon.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d0njk7403bexl
|
||||
83
scenes/ui/SettingsSlider.gd
Normal file
83
scenes/ui/SettingsSlider.gd
Normal file
@@ -0,0 +1,83 @@
|
||||
extends Control
|
||||
|
||||
# ============================================================================
|
||||
# SettingsSlider.gd - 设置面板自绘滑条
|
||||
# ============================================================================
|
||||
# 使用白色圆形滑块和柔和轨道,贴近概念图里的轻量滑条样式。
|
||||
# ============================================================================
|
||||
|
||||
signal value_changed(value: float)
|
||||
|
||||
const TRACK_COLOR: Color = Color(0.842, 0.894, 0.936, 0.95)
|
||||
const FILL_COLOR: Color = Color(0.258824, 0.627451, 0.913725, 1.0)
|
||||
const KNOB_COLOR: Color = Color(1.0, 1.0, 1.0, 1.0)
|
||||
const KNOB_SHADOW_COLOR: Color = Color(0.125, 0.282, 0.408, 0.16)
|
||||
|
||||
var value: float = 1.0
|
||||
var _dragging: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
custom_minimum_size = Vector2(360, 34)
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
|
||||
func set_value(newValue: float, emitSignal: bool = false) -> void:
|
||||
var clamped: float = clampf(newValue, 0.0, 1.0)
|
||||
if abs(value - clamped) < 0.001:
|
||||
return
|
||||
value = clamped
|
||||
queue_redraw()
|
||||
if emitSignal:
|
||||
value_changed.emit(value)
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton:
|
||||
var mouseEvent := event as InputEventMouseButton
|
||||
if mouseEvent.button_index != MOUSE_BUTTON_LEFT:
|
||||
return
|
||||
_dragging = mouseEvent.pressed
|
||||
if _dragging:
|
||||
_update_from_x(mouseEvent.position.x)
|
||||
accept_event()
|
||||
return
|
||||
|
||||
if event is InputEventMouseMotion and _dragging:
|
||||
var motionEvent := event as InputEventMouseMotion
|
||||
_update_from_x(motionEvent.position.x)
|
||||
accept_event()
|
||||
|
||||
func _draw() -> void:
|
||||
var trackHeight: float = 8.0
|
||||
var knobRadius: float = 12.0
|
||||
var left: float = knobRadius + 1.0
|
||||
var right: float = size.x - knobRadius - 1.0
|
||||
var centerY: float = size.y * 0.5
|
||||
var width: float = max(1.0, right - left)
|
||||
var knobX: float = left + width * value
|
||||
|
||||
var trackRect: Rect2 = Rect2(Vector2(left, centerY - trackHeight * 0.5), Vector2(width, trackHeight))
|
||||
draw_style_box(_round_box(TRACK_COLOR, int(trackHeight * 0.5)), trackRect)
|
||||
|
||||
var fillRect: Rect2 = Rect2(trackRect.position, Vector2(max(trackHeight, knobX - left), trackHeight))
|
||||
draw_style_box(_round_box(FILL_COLOR, int(trackHeight * 0.5)), fillRect)
|
||||
|
||||
var knobCenter: Vector2 = Vector2(knobX, centerY)
|
||||
draw_circle(knobCenter + Vector2(0, 1.4), knobRadius + 1.0, KNOB_SHADOW_COLOR)
|
||||
draw_circle(knobCenter, knobRadius, KNOB_COLOR)
|
||||
draw_arc(knobCenter, knobRadius, 0.0, TAU, 32, Color(0.790, 0.848, 0.902, 0.75), 1.0, true)
|
||||
|
||||
func _update_from_x(x: float) -> void:
|
||||
var knobRadius: float = 12.0
|
||||
var left: float = knobRadius + 1.0
|
||||
var right: float = size.x - knobRadius - 1.0
|
||||
var width: float = max(1.0, right - left)
|
||||
set_value((x - left) / width, true)
|
||||
|
||||
func _round_box(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
|
||||
return style
|
||||
1
scenes/ui/SettingsSlider.gd.uid
Normal file
1
scenes/ui/SettingsSlider.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://jnvemq1xr6hr
|
||||
45
scenes/ui/SettingsToggle.gd
Normal file
45
scenes/ui/SettingsToggle.gd
Normal file
@@ -0,0 +1,45 @@
|
||||
extends Button
|
||||
|
||||
# ============================================================================
|
||||
# SettingsToggle.gd - 设置面板开关控件
|
||||
# ============================================================================
|
||||
# 轻量自绘开关,避免默认 CheckButton 的厚重视觉。
|
||||
# ============================================================================
|
||||
|
||||
const ON_COLOR: Color = Color(0.337, 0.690, 0.934, 1.0)
|
||||
const OFF_COLOR: Color = Color(0.805, 0.843, 0.882, 1.0)
|
||||
const KNOB_COLOR: Color = Color(1.0, 1.0, 1.0, 1.0)
|
||||
const SHADOW_COLOR: Color = Color(0.114, 0.286, 0.420, 0.15)
|
||||
|
||||
func _ready() -> void:
|
||||
custom_minimum_size = Vector2(74, 40)
|
||||
focus_mode = Control.FOCUS_NONE
|
||||
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
toggle_mode = true
|
||||
text = ""
|
||||
add_theme_stylebox_override("normal", StyleBoxEmpty.new())
|
||||
add_theme_stylebox_override("hover", StyleBoxEmpty.new())
|
||||
add_theme_stylebox_override("pressed", StyleBoxEmpty.new())
|
||||
add_theme_stylebox_override("focus", StyleBoxEmpty.new())
|
||||
toggled.connect(func(_pressed: bool) -> void: queue_redraw())
|
||||
|
||||
func _draw() -> void:
|
||||
var trackRect: Rect2 = Rect2(Vector2(6, 7), Vector2(size.x - 12, size.y - 14))
|
||||
var radius: float = trackRect.size.y * 0.5
|
||||
var color: Color = ON_COLOR if button_pressed else OFF_COLOR
|
||||
draw_style_box(_create_round_box(color, int(radius)), trackRect)
|
||||
|
||||
var knobRadius: float = max(10.0, trackRect.size.y * 0.38)
|
||||
var x: float = trackRect.position.x + trackRect.size.x - radius if button_pressed else trackRect.position.x + radius
|
||||
var center: Vector2 = Vector2(x, trackRect.position.y + radius)
|
||||
draw_circle(center + Vector2(0, 1.2), knobRadius + 0.4, SHADOW_COLOR)
|
||||
draw_circle(center, knobRadius, KNOB_COLOR)
|
||||
|
||||
func _create_round_box(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
|
||||
return style
|
||||
1
scenes/ui/SettingsToggle.gd.uid
Normal file
1
scenes/ui/SettingsToggle.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bqxtvijb3kakm
|
||||
293
scenes/ui/WelcomeDialog.gd
Normal file
293
scenes/ui/WelcomeDialog.gd
Normal file
@@ -0,0 +1,293 @@
|
||||
extends CanvasLayer
|
||||
class_name WelcomeDialog
|
||||
|
||||
const GUIDE_PAGES: Array[Dictionary] = [
|
||||
{
|
||||
"text": "欢迎来到 [color=#3399ff]Datawhale Town[/color]!\n\n这里是开源学习者的家园。在这里,我们一同探索知识,分享成长。\n\n[center]WhaleTown[/center]",
|
||||
"image_path": "res://assets/maps/square/v1/props/bottom_entrance_left_task_props_v2_hd_clean.png",
|
||||
},
|
||||
{
|
||||
"text": "最新活动:\n\n- 镇长在公会大厅附近接待新伙伴。\n- 码头区域已经开放,可以和码头 NPC 交流。\n- 广场公告板后续会同步线上新闻动态。",
|
||||
"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- 输入框获得焦点时,角色移动会暂停响应。",
|
||||
"image_path": "res://assets/maps/square/v1/props/bottom_entrance_right_service_props_v2_hd_clean.png",
|
||||
},
|
||||
]
|
||||
|
||||
@onready var panelContainer: PanelContainer = $CenterContainer/PanelContainer
|
||||
@onready var vboxContainer: VBoxContainer = $CenterContainer/PanelContainer/VBoxContainer
|
||||
@onready var titleLabel: Label = $CenterContainer/PanelContainer/VBoxContainer/Header/Title
|
||||
@onready var logoContainer: PanelContainer = $CenterContainer/PanelContainer/VBoxContainer/LogoContainer
|
||||
@onready var bodyText: Label = $CenterContainer/PanelContainer/VBoxContainer/BodyText
|
||||
@onready var actionContainer: CenterContainer = $CenterContainer/PanelContainer/VBoxContainer/ActionContainer
|
||||
|
||||
var _chatUi: Control
|
||||
var _chatUiPrevMouseFilter: Control.MouseFilter = Control.MOUSE_FILTER_STOP
|
||||
var _chatUiMouseDisabled: bool = false
|
||||
var _guideContentContainer: HBoxContainer
|
||||
var _guideImageRect: TextureRect
|
||||
var _guideImageLabel: Label
|
||||
var _guideContentLabel: RichTextLabel
|
||||
var _guideFooter: HBoxContainer
|
||||
var _guidePrevButton: Button
|
||||
var _guideNextButton: Button
|
||||
var _guideDotsContainer: HBoxContainer
|
||||
var _guideCurrentPage: int = 0
|
||||
var _guideTween: Tween
|
||||
|
||||
func _ready() -> void:
|
||||
_disableChatUiMouseInput()
|
||||
|
||||
var closeButton := find_child("CloseButton", true, false) as Button
|
||||
if closeButton != null:
|
||||
closeButton.pressed.connect(_onClosePressed)
|
||||
|
||||
var startButton := find_child("StartButton", true, false) as Button
|
||||
if startButton != null:
|
||||
startButton.pressed.connect(_onStartPressed)
|
||||
|
||||
func _exit_tree() -> void:
|
||||
_restoreChatUiMouseInput()
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
queue_free()
|
||||
|
||||
func _onClosePressed() -> void:
|
||||
queue_free()
|
||||
|
||||
func _onStartPressed() -> void:
|
||||
_showGuide()
|
||||
|
||||
func _showGuide() -> void:
|
||||
panelContainer.custom_minimum_size = Vector2(720, 560)
|
||||
titleLabel.text = "新人引导手册"
|
||||
logoContainer.visible = false
|
||||
bodyText.visible = false
|
||||
actionContainer.visible = false
|
||||
|
||||
if _guideContentContainer == null:
|
||||
_buildGuideUi()
|
||||
|
||||
_guideContentContainer.visible = true
|
||||
_guideFooter.visible = true
|
||||
_guideCurrentPage = 0
|
||||
_setupGuideDots()
|
||||
_updateGuideUi(false)
|
||||
|
||||
func _buildGuideUi() -> void:
|
||||
_guideContentContainer = HBoxContainer.new()
|
||||
_guideContentContainer.name = "GuideContentContainer"
|
||||
_guideContentContainer.custom_minimum_size = Vector2(0, 360)
|
||||
_guideContentContainer.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
_guideContentContainer.add_theme_constant_override("separation", 22)
|
||||
vboxContainer.add_child(_guideContentContainer)
|
||||
|
||||
var imagePanel := PanelContainer.new()
|
||||
imagePanel.name = "ImagePanel"
|
||||
imagePanel.custom_minimum_size = Vector2(322, 0)
|
||||
imagePanel.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
imagePanel.add_theme_stylebox_override("panel", _createImageStyle())
|
||||
_guideContentContainer.add_child(imagePanel)
|
||||
|
||||
_guideImageRect = TextureRect.new()
|
||||
_guideImageRect.name = "ImageRect"
|
||||
_guideImageRect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
_guideImageRect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
imagePanel.add_child(_guideImageRect)
|
||||
|
||||
_guideImageLabel = Label.new()
|
||||
_guideImageLabel.name = "ImageLabel"
|
||||
_guideImageLabel.text = "暂无图片"
|
||||
_guideImageLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_guideImageLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_guideImageLabel.add_theme_color_override("font_color", Color(0.43, 0.48, 0.45, 1.0))
|
||||
_guideImageLabel.add_theme_font_size_override("font_size", 18)
|
||||
imagePanel.add_child(_guideImageLabel)
|
||||
|
||||
var textPanel := PanelContainer.new()
|
||||
textPanel.name = "TextPanel"
|
||||
textPanel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
textPanel.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
textPanel.add_theme_stylebox_override("panel", _createTextStyle())
|
||||
_guideContentContainer.add_child(textPanel)
|
||||
|
||||
_guideContentLabel = RichTextLabel.new()
|
||||
_guideContentLabel.name = "ContentLabel"
|
||||
_guideContentLabel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_guideContentLabel.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
_guideContentLabel.bbcode_enabled = true
|
||||
_guideContentLabel.fit_content = true
|
||||
_guideContentLabel.scroll_active = false
|
||||
_guideContentLabel.add_theme_color_override("default_color", Color(0.18, 0.2, 0.18, 1.0))
|
||||
_guideContentLabel.add_theme_font_size_override("normal_font_size", 21)
|
||||
textPanel.add_child(_guideContentLabel)
|
||||
|
||||
_guideFooter = HBoxContainer.new()
|
||||
_guideFooter.name = "GuideFooter"
|
||||
_guideFooter.custom_minimum_size = Vector2(0, 58)
|
||||
_guideFooter.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
_guideFooter.add_theme_constant_override("separation", 22)
|
||||
vboxContainer.add_child(_guideFooter)
|
||||
|
||||
_guidePrevButton = _createGuideButton("<")
|
||||
_guidePrevButton.name = "PrevButton"
|
||||
_guidePrevButton.pressed.connect(_onGuidePrevPressed)
|
||||
_guideFooter.add_child(_guidePrevButton)
|
||||
|
||||
_guideDotsContainer = HBoxContainer.new()
|
||||
_guideDotsContainer.name = "DotsContainer"
|
||||
_guideDotsContainer.custom_minimum_size = Vector2(88, 0)
|
||||
_guideDotsContainer.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
_guideDotsContainer.add_theme_constant_override("separation", 10)
|
||||
_guideFooter.add_child(_guideDotsContainer)
|
||||
|
||||
_guideNextButton = _createGuideButton(">")
|
||||
_guideNextButton.name = "NextButton"
|
||||
_guideNextButton.pressed.connect(_onGuideNextPressed)
|
||||
_guideFooter.add_child(_guideNextButton)
|
||||
|
||||
func _createGuideButton(text: String) -> Button:
|
||||
var button := Button.new()
|
||||
button.custom_minimum_size = Vector2(56, 44)
|
||||
button.text = text
|
||||
button.add_theme_color_override("font_color", Color.WHITE)
|
||||
button.add_theme_font_size_override("font_size", 22)
|
||||
button.add_theme_stylebox_override("normal", _createButtonStyle(Color(0.2, 0.43, 0.48, 1.0)))
|
||||
button.add_theme_stylebox_override("hover", _createButtonStyle(Color(0.28, 0.52, 0.55, 1.0)))
|
||||
button.add_theme_stylebox_override("pressed", _createButtonStyle(Color(0.28, 0.52, 0.55, 1.0)))
|
||||
button.add_theme_stylebox_override("disabled", _createButtonStyle(Color(0.72, 0.76, 0.72, 1.0)))
|
||||
return button
|
||||
|
||||
func _createImageStyle() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.content_margin_left = 10.0
|
||||
style.content_margin_top = 10.0
|
||||
style.content_margin_right = 10.0
|
||||
style.content_margin_bottom = 10.0
|
||||
style.bg_color = Color(0.875, 0.93, 0.92, 1.0)
|
||||
style.border_width_left = 2
|
||||
style.border_width_top = 2
|
||||
style.border_width_right = 2
|
||||
style.border_width_bottom = 2
|
||||
style.border_color = Color(0.55, 0.68, 0.64, 1.0)
|
||||
style.corner_radius_top_left = 10
|
||||
style.corner_radius_top_right = 10
|
||||
style.corner_radius_bottom_right = 10
|
||||
style.corner_radius_bottom_left = 10
|
||||
return style
|
||||
|
||||
func _createTextStyle() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.content_margin_left = 20.0
|
||||
style.content_margin_top = 18.0
|
||||
style.content_margin_right = 20.0
|
||||
style.content_margin_bottom = 18.0
|
||||
style.bg_color = Color(1.0, 0.985, 0.935, 1.0)
|
||||
style.border_width_left = 1
|
||||
style.border_width_top = 1
|
||||
style.border_width_right = 1
|
||||
style.border_width_bottom = 1
|
||||
style.border_color = Color(0.78, 0.7, 0.55, 1.0)
|
||||
style.corner_radius_top_left = 10
|
||||
style.corner_radius_top_right = 10
|
||||
style.corner_radius_bottom_right = 10
|
||||
style.corner_radius_bottom_left = 10
|
||||
return style
|
||||
|
||||
func _createButtonStyle(color: Color) -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.content_margin_left = 16.0
|
||||
style.content_margin_top = 8.0
|
||||
style.content_margin_right = 16.0
|
||||
style.content_margin_bottom = 8.0
|
||||
style.bg_color = color
|
||||
style.corner_radius_top_left = 8
|
||||
style.corner_radius_top_right = 8
|
||||
style.corner_radius_bottom_right = 8
|
||||
style.corner_radius_bottom_left = 8
|
||||
return style
|
||||
|
||||
func _setupGuideDots() -> void:
|
||||
for child in _guideDotsContainer.get_children():
|
||||
child.queue_free()
|
||||
|
||||
for index in range(GUIDE_PAGES.size()):
|
||||
var dot := ColorRect.new()
|
||||
dot.custom_minimum_size = Vector2(12, 12)
|
||||
_guideDotsContainer.add_child(dot)
|
||||
|
||||
func _updateGuideUi(animate: bool = true) -> void:
|
||||
_guidePrevButton.disabled = _guideCurrentPage == 0
|
||||
_guideNextButton.disabled = _guideCurrentPage == GUIDE_PAGES.size() - 1
|
||||
|
||||
var dots := _guideDotsContainer.get_children()
|
||||
for index in range(dots.size()):
|
||||
var dot := dots[index] as ColorRect
|
||||
if dot == null:
|
||||
continue
|
||||
if index == _guideCurrentPage:
|
||||
dot.color = Color(0.15, 0.36, 0.44, 1.0)
|
||||
dot.custom_minimum_size = Vector2(18, 12)
|
||||
else:
|
||||
dot.color = Color(0.72, 0.78, 0.72, 1.0)
|
||||
dot.custom_minimum_size = Vector2(12, 12)
|
||||
|
||||
if animate:
|
||||
_animateGuideContentChange()
|
||||
else:
|
||||
_setGuideContentImmediate()
|
||||
|
||||
func _setGuideContentImmediate() -> void:
|
||||
var page := GUIDE_PAGES[_guideCurrentPage]
|
||||
_guideContentLabel.text = page.get("text", "") as String
|
||||
|
||||
var imagePath := page.get("image_path", "") as String
|
||||
if imagePath != "" and ResourceLoader.exists(imagePath):
|
||||
_guideImageRect.texture = load(imagePath) as Texture2D
|
||||
_guideImageLabel.visible = false
|
||||
else:
|
||||
_guideImageRect.texture = null
|
||||
_guideImageLabel.visible = true
|
||||
_guideImageLabel.text = "暂无图片"
|
||||
|
||||
func _animateGuideContentChange() -> void:
|
||||
if _guideTween != null and _guideTween.is_valid():
|
||||
_guideTween.kill()
|
||||
|
||||
_guideTween = create_tween()
|
||||
_guideTween.tween_property(_guideContentContainer, "modulate:a", 0.0, 0.15)
|
||||
_guideTween.tween_callback(_setGuideContentImmediate)
|
||||
_guideTween.tween_property(_guideContentContainer, "modulate:a", 1.0, 0.15)
|
||||
|
||||
func _onGuidePrevPressed() -> void:
|
||||
if _guideCurrentPage > 0:
|
||||
_guideCurrentPage -= 1
|
||||
_updateGuideUi()
|
||||
|
||||
func _onGuideNextPressed() -> void:
|
||||
if _guideCurrentPage < GUIDE_PAGES.size() - 1:
|
||||
_guideCurrentPage += 1
|
||||
_updateGuideUi()
|
||||
|
||||
func _disableChatUiMouseInput() -> void:
|
||||
var currentScene := get_tree().current_scene
|
||||
if currentScene != null:
|
||||
_chatUi = currentScene.get_node_or_null("UILayer/ChatUI") as Control
|
||||
|
||||
if _chatUi == null:
|
||||
_chatUi = get_tree().root.find_child("ChatUI", true, false) as Control
|
||||
|
||||
if _chatUi != null:
|
||||
_chatUiPrevMouseFilter = _chatUi.mouse_filter
|
||||
_chatUi.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_chatUiMouseDisabled = true
|
||||
|
||||
func _restoreChatUiMouseInput() -> void:
|
||||
if _chatUiMouseDisabled and is_instance_valid(_chatUi):
|
||||
_chatUi.mouse_filter = _chatUiPrevMouseFilter
|
||||
|
||||
_chatUiMouseDisabled = false
|
||||
_chatUi = null
|
||||
1
scenes/ui/WelcomeDialog.gd.uid
Normal file
1
scenes/ui/WelcomeDialog.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://s86vkjh20re5
|
||||
8
scenes/ui/datawhale_honor_ranking_panel.tscn
Normal file
8
scenes/ui/datawhale_honor_ranking_panel.tscn
Normal file
@@ -0,0 +1,8 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/ui/DatawhaleHonorRankingPanel.gd" id="1_script"]
|
||||
|
||||
[node name="DatawhaleHonorRankingPanel" type="CanvasLayer"]
|
||||
process_mode = 3
|
||||
layer = 60
|
||||
script = ExtResource("1_script")
|
||||
177
scenes/ui/mall/MallItemCard.gd
Normal file
177
scenes/ui/mall/MallItemCard.gd
Normal file
@@ -0,0 +1,177 @@
|
||||
class_name MallItemCard
|
||||
extends Button
|
||||
|
||||
# ============================================================================
|
||||
# MallItemCard.gd - 商城商品卡片
|
||||
# ============================================================================
|
||||
# 展示商品槽位、状态、价格和选中态,由 MallPanel 统一驱动。
|
||||
# ============================================================================
|
||||
|
||||
signal item_selected(item: Dictionary)
|
||||
|
||||
const TEXT_COLOR: Color = Color(0.111, 0.231, 0.380)
|
||||
const MUTED_COLOR: Color = Color(0.486, 0.584, 0.694)
|
||||
const ACCENT_COLOR: Color = Color(0.086, 0.608, 0.922)
|
||||
const LOCKED_COLOR: Color = Color(0.455, 0.510, 0.584)
|
||||
const COIN_TEXTURE: Texture2D = preload("res://assets/ui/mall/branding/branding_whale_coin.png")
|
||||
const CATALOG_SCRIPT: Script = preload("res://Config/MallCatalog.gd")
|
||||
|
||||
var itemData: Dictionary = {}
|
||||
var isSelected: bool = false
|
||||
|
||||
var _background: PanelContainer
|
||||
var _icon: TextureRect
|
||||
var _nameLabel: Label
|
||||
var _statusLabel: Label
|
||||
var _priceLabel: Label
|
||||
var _lockedOverlay: ColorRect
|
||||
|
||||
func _ready() -> void:
|
||||
focus_mode = Control.FOCUS_NONE
|
||||
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
custom_minimum_size = Vector2(218, 288)
|
||||
add_theme_stylebox_override("normal", StyleBoxEmpty.new())
|
||||
add_theme_stylebox_override("hover", StyleBoxEmpty.new())
|
||||
add_theme_stylebox_override("pressed", StyleBoxEmpty.new())
|
||||
add_theme_stylebox_override("focus", StyleBoxEmpty.new())
|
||||
_build_ui()
|
||||
pressed.connect(_on_pressed)
|
||||
|
||||
func setup(data: Dictionary, selected: bool) -> void:
|
||||
itemData = data.duplicate(true)
|
||||
isSelected = selected
|
||||
if not is_inside_tree():
|
||||
return
|
||||
_render()
|
||||
|
||||
func set_selected(selected: bool) -> void:
|
||||
isSelected = selected
|
||||
if is_inside_tree():
|
||||
_render()
|
||||
|
||||
func _build_ui() -> void:
|
||||
_background = PanelContainer.new()
|
||||
_background.name = "background"
|
||||
_background.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_background.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(_background)
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.name = "contentMargin"
|
||||
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
margin.add_theme_constant_override("margin_left", 14)
|
||||
margin.add_theme_constant_override("margin_top", 14)
|
||||
margin.add_theme_constant_override("margin_right", 14)
|
||||
margin.add_theme_constant_override("margin_bottom", 14)
|
||||
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(margin)
|
||||
|
||||
var content := VBoxContainer.new()
|
||||
content.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
content.add_theme_constant_override("separation", 7)
|
||||
content.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
margin.add_child(content)
|
||||
|
||||
_statusLabel = Label.new()
|
||||
_statusLabel.custom_minimum_size = Vector2(0, 24)
|
||||
_statusLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
_statusLabel.add_theme_font_size_override("font_size", 15)
|
||||
_statusLabel.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
content.add_child(_statusLabel)
|
||||
|
||||
_icon = TextureRect.new()
|
||||
_icon.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
|
||||
_icon.custom_minimum_size = Vector2(152, 134)
|
||||
_icon.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL
|
||||
_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
content.add_child(_icon)
|
||||
|
||||
_nameLabel = Label.new()
|
||||
_nameLabel.custom_minimum_size = Vector2(0, 48)
|
||||
_nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_nameLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_nameLabel.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_nameLabel.add_theme_font_size_override("font_size", 19)
|
||||
_nameLabel.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
content.add_child(_nameLabel)
|
||||
|
||||
var priceRow := HBoxContainer.new()
|
||||
priceRow.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
priceRow.add_theme_constant_override("separation", 4)
|
||||
priceRow.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
content.add_child(priceRow)
|
||||
|
||||
var coin := TextureRect.new()
|
||||
coin.texture = COIN_TEXTURE
|
||||
coin.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
|
||||
coin.custom_minimum_size = Vector2(28, 28)
|
||||
coin.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL
|
||||
coin.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
coin.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
priceRow.add_child(coin)
|
||||
|
||||
_priceLabel = Label.new()
|
||||
_priceLabel.add_theme_font_size_override("font_size", 18)
|
||||
_priceLabel.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
priceRow.add_child(_priceLabel)
|
||||
|
||||
_lockedOverlay = ColorRect.new()
|
||||
_lockedOverlay.name = "lockedOverlay"
|
||||
_lockedOverlay.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_lockedOverlay.color = Color(0.350, 0.420, 0.510, 0.18)
|
||||
_lockedOverlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(_lockedOverlay)
|
||||
|
||||
_render()
|
||||
|
||||
func _render() -> void:
|
||||
_background.add_theme_stylebox_override("panel", _create_card_style(isSelected))
|
||||
var status := str(itemData.get("status", CATALOG_SCRIPT.STATUS_COMING_SOON))
|
||||
var iconPath := str(itemData.get("icon", ""))
|
||||
_icon.texture = _load_texture(iconPath)
|
||||
_nameLabel.text = str(itemData.get("name", "商品槽位"))
|
||||
_statusLabel.text = CATALOG_SCRIPT.get_status_label(status)
|
||||
_priceLabel.text = _format_price(itemData)
|
||||
_lockedOverlay.visible = status == CATALOG_SCRIPT.STATUS_COMING_SOON or status == CATALOG_SCRIPT.STATUS_LOCKED
|
||||
_statusLabel.add_theme_color_override("font_color", ACCENT_COLOR if status == CATALOG_SCRIPT.STATUS_AVAILABLE else LOCKED_COLOR)
|
||||
|
||||
func _create_card_style(selected: bool) -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.982, 0.995, 1.0, 1.0)
|
||||
style.corner_radius_top_left = 18
|
||||
style.corner_radius_top_right = 18
|
||||
style.corner_radius_bottom_left = 18
|
||||
style.corner_radius_bottom_right = 18
|
||||
style.border_width_left = 2 if not selected else 4
|
||||
style.border_width_top = 2 if not selected else 4
|
||||
style.border_width_right = 2 if not selected else 4
|
||||
style.border_width_bottom = 2 if not selected else 4
|
||||
style.border_color = Color(0.658, 0.835, 0.965, 0.86) if not selected else Color(0.086, 0.690, 0.960, 1.0)
|
||||
style.shadow_color = Color(0.086, 0.314, 0.520, 0.15 if selected else 0.08)
|
||||
style.shadow_size = 14 if selected else 8
|
||||
style.shadow_offset = Vector2(0, 4)
|
||||
return style
|
||||
|
||||
func _format_price(data: Dictionary) -> String:
|
||||
var status := str(data.get("status", ""))
|
||||
if status == CATALOG_SCRIPT.STATUS_OWNED:
|
||||
return "已购买"
|
||||
if status == CATALOG_SCRIPT.STATUS_COMING_SOON or status == CATALOG_SCRIPT.STATUS_LOCKED:
|
||||
return "暂未开放"
|
||||
return str(int(data.get("price", 0)))
|
||||
|
||||
func _load_texture(path: String) -> Texture2D:
|
||||
if path.is_empty():
|
||||
return null
|
||||
if FileAccess.file_exists("%s.import" % path):
|
||||
var texture := load(path) as Texture2D
|
||||
if texture != null:
|
||||
return texture
|
||||
var image := Image.load_from_file(ProjectSettings.globalize_path(path))
|
||||
if image == null or image.is_empty():
|
||||
return null
|
||||
return ImageTexture.create_from_image(image)
|
||||
|
||||
func _on_pressed() -> void:
|
||||
item_selected.emit(itemData)
|
||||
1
scenes/ui/mall/MallItemCard.gd.uid
Normal file
1
scenes/ui/mall/MallItemCard.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://buh0lhtug6bw8
|
||||
1211
scenes/ui/mall/MallPanel.gd
Normal file
1211
scenes/ui/mall/MallPanel.gd
Normal file
File diff suppressed because it is too large
Load Diff
1
scenes/ui/mall/MallPanel.gd.uid
Normal file
1
scenes/ui/mall/MallPanel.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bej4ywlrtg1tb
|
||||
13
scenes/ui/mall/MallPanel.tscn
Normal file
13
scenes/ui/mall/MallPanel.tscn
Normal file
@@ -0,0 +1,13 @@
|
||||
[gd_scene load_steps=2 format=4 uid="uid://whaletown_mall_panel"]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/ui/mall/MallPanel.gd" id="1_mall_panel"]
|
||||
|
||||
[node name="MallPanel" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_mall_panel")
|
||||
232
scenes/ui/notice_dialog.tscn
Normal file
232
scenes/ui/notice_dialog.tscn
Normal file
@@ -0,0 +1,232 @@
|
||||
[gd_scene load_steps=10 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/ui/NoticeDialog.gd" id="1_script"]
|
||||
[ext_resource type="Theme" path="res://assets/ui/world_text_theme.tres" id="2_theme"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_panel"]
|
||||
content_margin_left = 30.0
|
||||
content_margin_top = 24.0
|
||||
content_margin_right = 30.0
|
||||
content_margin_bottom = 24.0
|
||||
bg_color = Color(0.975, 0.955, 0.89, 1)
|
||||
border_width_left = 3
|
||||
border_width_top = 3
|
||||
border_width_right = 3
|
||||
border_width_bottom = 3
|
||||
border_color = Color(0.28, 0.43, 0.42, 1)
|
||||
corner_radius_top_left = 14
|
||||
corner_radius_top_right = 14
|
||||
corner_radius_bottom_right = 14
|
||||
corner_radius_bottom_left = 14
|
||||
shadow_color = Color(0.05, 0.08, 0.08, 0.32)
|
||||
shadow_size = 18
|
||||
shadow_offset = Vector2(0, 8)
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_image"]
|
||||
content_margin_left = 10.0
|
||||
content_margin_top = 10.0
|
||||
content_margin_right = 10.0
|
||||
content_margin_bottom = 10.0
|
||||
bg_color = Color(0.875, 0.93, 0.92, 1)
|
||||
border_width_left = 2
|
||||
border_width_top = 2
|
||||
border_width_right = 2
|
||||
border_width_bottom = 2
|
||||
border_color = Color(0.55, 0.68, 0.64, 1)
|
||||
corner_radius_top_left = 10
|
||||
corner_radius_top_right = 10
|
||||
corner_radius_bottom_right = 10
|
||||
corner_radius_bottom_left = 10
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_text"]
|
||||
content_margin_left = 20.0
|
||||
content_margin_top = 18.0
|
||||
content_margin_right = 20.0
|
||||
content_margin_bottom = 18.0
|
||||
bg_color = Color(1, 0.985, 0.935, 1)
|
||||
border_width_left = 1
|
||||
border_width_top = 1
|
||||
border_width_right = 1
|
||||
border_width_bottom = 1
|
||||
border_color = Color(0.78, 0.7, 0.55, 1)
|
||||
corner_radius_top_left = 10
|
||||
corner_radius_top_right = 10
|
||||
corner_radius_bottom_right = 10
|
||||
corner_radius_bottom_left = 10
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button"]
|
||||
content_margin_left = 16.0
|
||||
content_margin_top = 8.0
|
||||
content_margin_right = 16.0
|
||||
content_margin_bottom = 8.0
|
||||
bg_color = Color(0.2, 0.43, 0.48, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_hover"]
|
||||
content_margin_left = 16.0
|
||||
content_margin_top = 8.0
|
||||
content_margin_right = 16.0
|
||||
content_margin_bottom = 8.0
|
||||
bg_color = Color(0.28, 0.52, 0.55, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_disabled"]
|
||||
content_margin_left = 16.0
|
||||
content_margin_top = 8.0
|
||||
content_margin_right = 16.0
|
||||
content_margin_bottom = 8.0
|
||||
bg_color = Color(0.72, 0.76, 0.72, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_close"]
|
||||
bg_color = Color(0.86, 0.33, 0.27, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[node name="NoticeDialog" type="CanvasLayer"]
|
||||
process_mode = 3
|
||||
script = ExtResource("1_script")
|
||||
|
||||
[node name="Dimmer" type="ColorRect" parent="."]
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
color = Color(0.03, 0.04, 0.04, 0.58)
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="."]
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="PanelContainer" type="PanelContainer" parent="CenterContainer"]
|
||||
custom_minimum_size = Vector2(720, 560)
|
||||
layout_mode = 2
|
||||
theme = ExtResource("2_theme")
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_panel")
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer/PanelContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 18
|
||||
|
||||
[node name="Header" type="HBoxContainer" parent="CenterContainer/PanelContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 52)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="LeftSpacer" type="Control" parent="CenterContainer/PanelContainer/VBoxContainer/Header"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="Title" type="Label" parent="CenterContainer/PanelContainer/VBoxContainer/Header"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.15, 0.26, 0.3, 1)
|
||||
theme_override_font_sizes/font_size = 30
|
||||
text = "公告板"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="RightContainer" type="HBoxContainer" parent="CenterContainer/PanelContainer/VBoxContainer/Header"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
alignment = 2
|
||||
|
||||
[node name="CloseButton" type="Button" parent="CenterContainer/PanelContainer/VBoxContainer/Header/RightContainer"]
|
||||
custom_minimum_size = Vector2(42, 42)
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(1, 1, 1, 1)
|
||||
theme_override_font_sizes/font_size = 20
|
||||
theme_override_styles/normal = SubResource("StyleBoxFlat_close")
|
||||
theme_override_styles/hover = SubResource("StyleBoxFlat_close")
|
||||
theme_override_styles/pressed = SubResource("StyleBoxFlat_close")
|
||||
text = "X"
|
||||
|
||||
[node name="ContentContainer" type="HBoxContainer" parent="CenterContainer/PanelContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 360)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/separation = 22
|
||||
|
||||
[node name="ImagePanel" type="PanelContainer" parent="CenterContainer/PanelContainer/VBoxContainer/ContentContainer"]
|
||||
custom_minimum_size = Vector2(322, 0)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_image")
|
||||
|
||||
[node name="ImageRect" type="TextureRect" parent="CenterContainer/PanelContainer/VBoxContainer/ContentContainer/ImagePanel"]
|
||||
layout_mode = 2
|
||||
expand_mode = 1
|
||||
stretch_mode = 5
|
||||
|
||||
[node name="ImageLabel" type="Label" parent="CenterContainer/PanelContainer/VBoxContainer/ContentContainer/ImagePanel"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.43, 0.48, 0.45, 1)
|
||||
theme_override_font_sizes/font_size = 18
|
||||
text = "暂无图片"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="TextPanel" type="PanelContainer" parent="CenterContainer/PanelContainer/VBoxContainer/ContentContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_text")
|
||||
|
||||
[node name="ContentLabel" type="RichTextLabel" parent="CenterContainer/PanelContainer/VBoxContainer/ContentContainer/TextPanel"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
theme_override_colors/default_color = Color(0.18, 0.2, 0.18, 1)
|
||||
theme_override_font_sizes/normal_font_size = 21
|
||||
bbcode_enabled = true
|
||||
fit_content = true
|
||||
scroll_active = false
|
||||
text = "Announcement Content..."
|
||||
|
||||
[node name="Footer" type="HBoxContainer" parent="CenterContainer/PanelContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 58)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 22
|
||||
alignment = 1
|
||||
|
||||
[node name="PrevButton" type="Button" parent="CenterContainer/PanelContainer/VBoxContainer/Footer"]
|
||||
custom_minimum_size = Vector2(56, 44)
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(1, 1, 1, 1)
|
||||
theme_override_font_sizes/font_size = 22
|
||||
theme_override_styles/normal = SubResource("StyleBoxFlat_button")
|
||||
theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover")
|
||||
theme_override_styles/pressed = SubResource("StyleBoxFlat_button_hover")
|
||||
theme_override_styles/disabled = SubResource("StyleBoxFlat_button_disabled")
|
||||
text = "<"
|
||||
|
||||
[node name="DotsContainer" type="HBoxContainer" parent="CenterContainer/PanelContainer/VBoxContainer/Footer"]
|
||||
custom_minimum_size = Vector2(88, 0)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
alignment = 1
|
||||
|
||||
[node name="NextButton" type="Button" parent="CenterContainer/PanelContainer/VBoxContainer/Footer"]
|
||||
custom_minimum_size = Vector2(56, 44)
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(1, 1, 1, 1)
|
||||
theme_override_font_sizes/font_size = 22
|
||||
theme_override_styles/normal = SubResource("StyleBoxFlat_button")
|
||||
theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover")
|
||||
theme_override_styles/pressed = SubResource("StyleBoxFlat_button_hover")
|
||||
theme_override_styles/disabled = SubResource("StyleBoxFlat_button_disabled")
|
||||
text = ">"
|
||||
169
scenes/ui/welcome_dialog.tscn
Normal file
169
scenes/ui/welcome_dialog.tscn
Normal file
@@ -0,0 +1,169 @@
|
||||
[gd_scene load_steps=9 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/ui/WelcomeDialog.gd" id="1_script"]
|
||||
[ext_resource type="Texture2D" path="res://assets/maps/square/v1/props/bottom_entrance_right_service_props_v2_hd_clean.png" id="2_board"]
|
||||
[ext_resource type="Theme" path="res://assets/ui/world_text_theme.tres" id="3_theme"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_card"]
|
||||
content_margin_left = 34.0
|
||||
content_margin_top = 28.0
|
||||
content_margin_right = 34.0
|
||||
content_margin_bottom = 28.0
|
||||
bg_color = Color(0.965, 0.95, 0.88, 1)
|
||||
border_width_left = 3
|
||||
border_width_top = 3
|
||||
border_width_right = 3
|
||||
border_width_bottom = 3
|
||||
border_color = Color(0.28, 0.42, 0.38, 1)
|
||||
corner_radius_top_left = 14
|
||||
corner_radius_top_right = 14
|
||||
corner_radius_bottom_right = 14
|
||||
corner_radius_bottom_left = 14
|
||||
shadow_color = Color(0.05, 0.08, 0.08, 0.32)
|
||||
shadow_size = 18
|
||||
shadow_offset = Vector2(0, 8)
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_image"]
|
||||
content_margin_left = 12.0
|
||||
content_margin_top = 12.0
|
||||
content_margin_right = 12.0
|
||||
content_margin_bottom = 12.0
|
||||
bg_color = Color(0.87, 0.93, 0.91, 1)
|
||||
border_width_left = 2
|
||||
border_width_top = 2
|
||||
border_width_right = 2
|
||||
border_width_bottom = 2
|
||||
border_color = Color(0.54, 0.67, 0.62, 1)
|
||||
corner_radius_top_left = 10
|
||||
corner_radius_top_right = 10
|
||||
corner_radius_bottom_right = 10
|
||||
corner_radius_bottom_left = 10
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button"]
|
||||
content_margin_left = 22.0
|
||||
content_margin_top = 10.0
|
||||
content_margin_right = 22.0
|
||||
content_margin_bottom = 10.0
|
||||
bg_color = Color(0.2, 0.43, 0.48, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_button_hover"]
|
||||
content_margin_left = 22.0
|
||||
content_margin_top = 10.0
|
||||
content_margin_right = 22.0
|
||||
content_margin_bottom = 10.0
|
||||
bg_color = Color(0.28, 0.52, 0.55, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_close"]
|
||||
bg_color = Color(0.86, 0.33, 0.27, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[node name="WelcomeDialog" type="CanvasLayer"]
|
||||
script = ExtResource("1_script")
|
||||
|
||||
[node name="ColorRect" type="ColorRect" parent="."]
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
color = Color(0.03, 0.04, 0.04, 0.52)
|
||||
|
||||
[node name="CenterContainer" type="CenterContainer" parent="."]
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="PanelContainer" type="PanelContainer" parent="CenterContainer"]
|
||||
custom_minimum_size = Vector2(680, 520)
|
||||
layout_mode = 2
|
||||
theme = ExtResource("3_theme")
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_card")
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer/PanelContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 18
|
||||
|
||||
[node name="Header" type="HBoxContainer" parent="CenterContainer/PanelContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 52)
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
alignment = 1
|
||||
|
||||
[node name="Spacer" type="Control" parent="CenterContainer/PanelContainer/VBoxContainer/Header"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="Title" type="Label" parent="CenterContainer/PanelContainer/VBoxContainer/Header"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.14, 0.25, 0.29, 1)
|
||||
theme_override_font_sizes/font_size = 28
|
||||
text = "Datawhale Town 信息板"
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="Spacer2" type="Control" parent="CenterContainer/PanelContainer/VBoxContainer/Header"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="CloseButton" type="Button" parent="CenterContainer/PanelContainer/VBoxContainer/Header"]
|
||||
custom_minimum_size = Vector2(42, 42)
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(1, 1, 1, 1)
|
||||
theme_override_font_sizes/font_size = 20
|
||||
theme_override_styles/normal = SubResource("StyleBoxFlat_close")
|
||||
theme_override_styles/hover = SubResource("StyleBoxFlat_close")
|
||||
theme_override_styles/pressed = SubResource("StyleBoxFlat_close")
|
||||
text = "X"
|
||||
|
||||
[node name="LogoContainer" type="PanelContainer" parent="CenterContainer/PanelContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 190)
|
||||
layout_mode = 2
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_image")
|
||||
|
||||
[node name="TextureRect" type="TextureRect" parent="CenterContainer/PanelContainer/VBoxContainer/LogoContainer"]
|
||||
layout_mode = 2
|
||||
texture = ExtResource("2_board")
|
||||
expand_mode = 1
|
||||
stretch_mode = 5
|
||||
|
||||
[node name="BodyText" type="Label" parent="CenterContainer/PanelContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 150)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
theme_override_colors/font_color = Color(0.18, 0.2, 0.18, 1)
|
||||
theme_override_font_sizes/font_size = 20
|
||||
text = "连接、共生、见证。
|
||||
Datawhale Town 是学习者的赛博家园与精神坐标。
|
||||
|
||||
实时广场:看大家都在学什么。
|
||||
个人空间:展示你的学习笔记与作品。
|
||||
开源营地:更有氛围的组队学习体验。"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
autowrap_mode = 3
|
||||
|
||||
[node name="ActionContainer" type="CenterContainer" parent="CenterContainer/PanelContainer/VBoxContainer"]
|
||||
custom_minimum_size = Vector2(0, 58)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="StartButton" type="Button" parent="CenterContainer/PanelContainer/VBoxContainer/ActionContainer"]
|
||||
custom_minimum_size = Vector2(190, 48)
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(1, 1, 1, 1)
|
||||
theme_override_font_sizes/font_size = 19
|
||||
theme_override_styles/normal = SubResource("StyleBoxFlat_button")
|
||||
theme_override_styles/hover = SubResource("StyleBoxFlat_button_hover")
|
||||
theme_override_styles/pressed = SubResource("StyleBoxFlat_button_hover")
|
||||
text = "开始探索"
|
||||
Reference in New Issue
Block a user