xiandie/scene/entity/npc.gd
2025-07-17 16:51:04 +08:00

374 lines
9.4 KiB
GDScript
Raw 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.

@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:
if _last_line_session >= 0:
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
}
]