Initial WhaleTown V2 frontend

This commit is contained in:
2026-07-19 22:42:20 +08:00
commit 435c578dde
1421 changed files with 54486 additions and 0 deletions

285
scenes/Maps/CafeInterior.gd Normal file
View 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

View File

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

View 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

View File

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

View 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

View File

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

View 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)

View File

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

42
scenes/Maps/Square.gd Normal file
View 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

View File

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

176
scenes/Maps/WorkZone.gd Normal file
View 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")

View File

@@ -0,0 +1 @@
uid://6de5gvosic2s

View 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])

View File

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

View 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)

View 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

File diff suppressed because one or more lines are too long

424
scenes/Maps/work_zone.tscn Normal file

File diff suppressed because one or more lines are too long

View 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

View File

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

View 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

View File

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

View 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

View File

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

View 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

View File

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

View 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")
}

View 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")
}

View 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")
}

View 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="."]

View 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")
}

View 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)

View File

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

View 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)

View File

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

View 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)

View File

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

View 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]

View File

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

View 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

File diff suppressed because it is too large Load Diff

View File

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

12
scenes/ui/AuthScene.tscn Normal file
View 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")

View 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

View File

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

View 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)

View File

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

View 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 = "发送"

View 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)

View File

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

View 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
View 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

View File

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

41
scenes/ui/ChatBubble.tscn Normal file
View 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

File diff suppressed because it is too large Load Diff

1
scenes/ui/ChatUI.gd.uid Normal file
View File

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

350
scenes/ui/ChatUI.tscn Normal file
View 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 = "➤"

View 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

View File

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

View 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")

File diff suppressed because it is too large Load Diff

View File

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

View 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

View File

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

View 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")

View 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

View File

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

516
scenes/ui/MapPanel.gd Normal file
View 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)

View File

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

13
scenes/ui/MapPanel.tscn Normal file
View 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
View 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

View File

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

489
scenes/ui/PlayerHud.gd Normal file
View 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")

View File

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

13
scenes/ui/PlayerHud.tscn Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
uid://3vltb77vwxy

View 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")

View 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

View File

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

View 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

View File

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

View 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

View File

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

293
scenes/ui/WelcomeDialog.gd Normal file
View 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

View File

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

View 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")

View 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)

View File

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

1211
scenes/ui/mall/MallPanel.gd Normal file

File diff suppressed because it is too large Load Diff

View File

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

View 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")

View 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 = ">"

View 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 = "开始探索"