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 @export var interactionDistance: float = 150.0 @onready var animation_player: AnimationPlayer = $AnimationPlayer var _nameplate: Label func _ready() -> void: if get_node_or_null("CafeCompanionTarget") == null: var interactable: InteractableComponent = InteractableComponent.new() interactable.interaction_id = "npc:%s" % str(get_path()) interactable.interaction_title = "与 %s 交谈" % npcName interactable.interaction_priority = 40 interactable.interaction_distance = interactionDistance interactable.activation_method = &"interact" add_child(interactable) # 播放场景里配置好的待机动画,让不同 NPC 可以复用同一个控制器。 if animation_player.has_animation("idle"): animation_player.play("idle") _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