@tool class_name Npc2D extends AnimatedSprite2D signal interacted signal talk_finished # 在 unlock player 之前发射 # 常量定义 const SPEAKING_SIGN_FADE_DURATION := 0.3 const SPEAKING_SCALE_MULTIPLIER := 1.3 enum SpeakingSignMode { HIDDEN = 0, SILENT = 1, SPEAKING = 2 } # 导出变量 @export var snap_to_edge := true @export var walk_to_edge_width := 25.0 @export var action_key := 4 @export var enabled := true: set(val): enabled = val if is_node_ready(): _align_signs_status() @export var sign_mark_height := 10.0: set(val): sign_mark_height = val if is_node_ready() and sign_mark: sign_mark.sign_mark_offset.y = -sign_mark_height @export var speaking_sign_height := 60.0: set(val): speaking_sign_height = val if is_node_ready() and speaking_sign: speaking_sign.position.y = -speaking_sign_height @export var sign_x_offset := 0.0: set(val): sign_x_offset = val if is_node_ready(): _update_sign_x_positions() @export var collision_width_and_x := Vector2(20.0, 0): set(val): collision_width_and_x = val if is_node_ready(): _update_collision_shape() # DialogueLine 播放 hook_character_name 的对话时,显示 speaking 标志 # 为空时不 hook @export var hook_character_name := "" # 节点引用 @onready var speaking_animation = %SpeakingAnimationPlayer @onready var speaking_sign = %SpeakingSign2D as Node2D @onready var sign_mark = %Sign as Sign @onready var sign_snapper = %SignSnapper as SignSnapper @onready var area2d = %Area2D as Area2D # 内部变量 var ground_archive: GroundArchive var icount: int: set(val): if icount == val: return icount = val if ground_archive: ground_archive.set_pair(name, "icount", val) _align_signs_status() var dialogue_title := "" var dialogue_res = preload("res://asset/dialogue/npc.dialogue") var base_scale := Vector2.ONE var base_mod := Color.WHITE_SMOKE var speaking_sign_tween: Tween var speaking_sign_mode := SpeakingSignMode.HIDDEN: set(val): if speaking_sign_mode == val: return speaking_sign_mode = val _update_speaking_sign_mode() # 强制播放状态管理 var is_hooked := false var hook_id := 0 # 用于追踪hook会话,避免异步问题 func _ready() -> void: _initialize_components() if Engine.is_editor_hint(): _setup_editor_preview() return _setup_game_mode() _align_signs_status() DialogueManager.dialogue_ended.connect(_on_dialogue_ended) DialogueManager.got_dialogue.connect(_on_got_dialogue) var _last_line_session := -1 func _on_got_dialogue(line: DialogueLine) -> void: if not hook_character_name: return # 去除 debug if line.character.begins_with(GlobalConfig.DEBUG_CHARACTER_PREFIX): return # 其他角色说话时会自动停止 if line.character == hook_character_name: # 未在 speaking 状态 if _last_line_session < 0: _last_line_session = hook_speaking() if GlobalConfig.DEBUG: print("[Npc2D] Hook speaking for character: ", hook_character_name, " line.character:", line.character) elif _last_line_session >= 0: if GlobalConfig.DEBUG: print("[Npc2D] Unhook speaking for character: ", hook_character_name, " line.character:", line.character) unhook_speaking(_last_line_session) _last_line_session = -1 # dialog 结束时会自动 unhook 从 line 播放的 speaking func _on_dialogue_ended(_res) -> void: unhook_speaking(_last_line_session) func _initialize_components() -> void: # 设置标记位置 if sign_mark: sign_mark.sign_mark_offset.y = -sign_mark_height sign_mark.position.x = sign_x_offset if speaking_sign: speaking_sign.position.y = -speaking_sign_height speaking_sign.position.x = sign_x_offset base_scale = speaking_sign.scale base_mod = speaking_sign.modulate speaking_sign.modulate.a = 0.0 # 设置碰撞形状 _update_collision_shape() # 配置 sign_snapper if sign_snapper: sign_snapper.action_on_arrived = action_key sign_snapper.radius = walk_to_edge_width sign_snapper.enabled = snap_to_edge func _setup_editor_preview() -> void: if speaking_sign: speaking_sign.visible = true speaking_sign.modulate.a = 1.0 var sprite = speaking_sign.get_node_or_null("Sprite2D") if sprite: sprite.position.x = -60.0 sprite.frame = 2 if sign_mark: sign_mark.display_sign = true func _setup_game_mode() -> void: # 获取存档数据 ground_archive = ArchiveManager.archive.ground_archive() icount = ground_archive.get_value(name, "icount", 0) # 连接信号 if snap_to_edge and sign_snapper: sign_snapper.arrived.connect(_on_interacted) elif sign_mark: sign_mark.interacted.connect(_on_interacted) if sign_mark: sign_mark.toggle_active.connect(_on_toggle_active) visibility_changed.connect(_on_visibility_changed) # 开始动画 if sprite_frames and animation: play() func _update_sign_x_positions() -> void: if speaking_sign: speaking_sign.position.x = sign_x_offset if sign_mark: sign_mark.position.x = sign_x_offset func _update_collision_shape() -> void: if not area2d: return var collision_shape = area2d.get_node_or_null("CollisionShape2D") if collision_shape and collision_shape.shape: collision_shape.shape.size.x = collision_width_and_x.x area2d.position.x = collision_width_and_x.y func _update_speaking_sign_mode() -> void: if not speaking_sign: return # 清理之前的补间动画 if speaking_sign_tween and speaking_sign_tween.is_valid(): speaking_sign_tween.kill() speaking_sign_tween = create_tween() match speaking_sign_mode: SpeakingSignMode.HIDDEN: speaking_sign_tween.tween_property( speaking_sign, "modulate:a", 0.0, SPEAKING_SIGN_FADE_DURATION ) if speaking_animation: speaking_animation.stop() SpeakingSignMode.SILENT: speaking_sign_tween.tween_property( speaking_sign, "modulate", base_mod, SPEAKING_SIGN_FADE_DURATION ) speaking_sign_tween.parallel().tween_property( speaking_sign, "scale", base_scale, SPEAKING_SIGN_FADE_DURATION ) if speaking_animation: speaking_animation.play("speaking") SpeakingSignMode.SPEAKING: speaking_sign_tween.tween_property( speaking_sign, "modulate", Color.WHITE, SPEAKING_SIGN_FADE_DURATION ) speaking_sign_tween.parallel().tween_property( speaking_sign, "scale", base_scale * SPEAKING_SCALE_MULTIPLIER, SPEAKING_SIGN_FADE_DURATION ) if speaking_animation: speaking_animation.play("speaking") func _on_visibility_changed() -> void: _align_signs_status() func _align_signs_status() -> void: if not is_node_ready(): return if Engine.is_editor_hint(): _setup_editor_preview() return var is_active = enabled and is_visible_in_tree() if sign_mark: sign_mark.enabled = is_active sign_mark.display_sign = icount == 0 if speaking_sign: speaking_sign.visible = enabled and (icount > 0 or is_hooked) func _on_toggle_active(activated: bool) -> void: # 如果处于hook状态,不响应正常的toggle if is_hooked: return if not activated: speaking_sign_mode = SpeakingSignMode.HIDDEN elif speaking_sign_mode == SpeakingSignMode.HIDDEN: speaking_sign_mode = SpeakingSignMode.SILENT func _on_interacted() -> void: if not dialogue_title: return # 如果正在hook播放,先取消hook if is_hooked: _cancel_hook() SceneManager.lock_player(0, action_key) icount += 1 if ground_archive: ground_archive.set_pair(name, "icount", icount) DialogueManager.show_dialogue_balloon(dialogue_res, dialogue_title) interacted.emit() var was_out_of_range = speaking_sign_mode == SpeakingSignMode.HIDDEN speaking_sign_mode = SpeakingSignMode.SPEAKING await DialogueManager.dialogue_ended speaking_sign_mode = SpeakingSignMode.HIDDEN if was_out_of_range else SpeakingSignMode.SILENT talk_finished.emit() SceneManager.unlock_player() # 新增:强制播放说话动画 func hook_speaking() -> int: # 强制显示说话气泡动画 # 返回值:hook会话ID,用于验证unhook的有效性 is_hooked = true hook_id += 1 var current_hook_id = hook_id # 确保speaking_sign可见 if speaking_sign and not speaking_sign.visible: speaking_sign.visible = true # 保存当前状态并切换到说话模式 speaking_sign_mode = SpeakingSignMode.SPEAKING return current_hook_id # 新增:退出强制播放 func unhook_speaking(session_id: int = -1) -> void: # 退出强制播放模式 # 参数: # session_id: hook会话ID,如果不匹配则忽略此次unhook # 如果已经不在hook状态,或session_id不匹配,则忽略 if not is_hooked or (session_id != -1 and session_id != hook_id): return _cancel_hook() # 新增:内部取消hook的方法 func _cancel_hook() -> void: # 内部使用的取消hook方法 is_hooked = false # 恢复到适当的状态 if sign_mark and sign_mark.activated: speaking_sign_mode = SpeakingSignMode.SILENT else: speaking_sign_mode = SpeakingSignMode.HIDDEN # 重新对齐显示状态 _align_signs_status() # 新增:检查是否正在hook播放 func is_hook_speaking() -> bool: # 返回当前是否处于强制播放状态 return is_hooked func _get(property: StringName) -> Variant: if property == "dialogue_title": return dialogue_title return null func _set(property: StringName, value: Variant) -> bool: if property == "dialogue_title": dialogue_title = value return true return false func _get_property_list() -> Array[Dictionary]: var hint_str = "" if Engine.is_editor_hint() and dialogue_res: hint_str = ",".join(dialogue_res.get_ordered_titles()) return [ { "name": "dialogue_title", "type": TYPE_STRING, "hint": PROPERTY_HINT_ENUM_SUGGESTION, "hint_string": hint_str } ]