Files
whale-town-front-v2/scenes/characters/NPCController.gd
2026-07-22 00:45:59 +08:00

169 lines
6.2 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:
var taskManager := get_node_or_null("/root/TaskManager")
if taskManager != null and taskManager.has_method("report_activity"):
taskManager.call("report_activity", "facility_interacted", "npc")
show_bubble(dialogue)
var eventSystem: Node = get_node_or_null("/root/EventSystem")
if eventSystem != null:
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