@tool extends Node3D class_name InteractableNPC ## Billboard NPC with dialog key and optional quest hook. ## @tool: applies texture/height in the editor so NPCs are WYSIWYG (no quest logic). signal interacted(npc: InteractableNPC) @export var dialog_key: String = "dialog_hongkong_npc" @export var texture_path: String = "res://assets/sprites/placeholders/npc.png": set(v): texture_path = v if Engine.is_editor_hint() and is_node_ready(): _apply_billboard_look() @export var billboard_height: float = 1.74: set(v): billboard_height = v if Engine.is_editor_hint() and is_node_ready(): _apply_billboard_look() @export var billboard_max_width: float = 2.2: set(v): billboard_max_width = v if Engine.is_editor_hint() and is_node_ready(): _apply_billboard_look() @export var can_interact: bool = true ## false = deco only (e.g. village house) @export var give_item_id: String = "" @export var give_once: bool = false ## Don't stack if player already has the item @export var despawn_after_give: bool = false ## Remove world pickup after taking @export var require_item_id: String = "" @export var consume_required: bool = false @export var quest_flag: String = "" @export var achievement_id: String = "" @export var on_success_goto: String = "" @export var prompt_key: String = "" ## Optional override, e.g. prompt_press_button @onready var _billboard: BillboardSprite = $BillboardSprite func _ready() -> void: _apply_billboard_look() if Engine.is_editor_hint(): return if can_interact: add_to_group("interactable") func _apply_billboard_look() -> void: if _billboard == null: _billboard = get_node_or_null("BillboardSprite") as BillboardSprite if _billboard == null: return _billboard.max_width = billboard_max_width _billboard.set_target_height(billboard_height) if texture_path != "" and ResourceLoader.exists(texture_path): var tex: Texture2D = load(texture_path) if tex: _billboard.set_texture(tex) func on_interact(_player: Node) -> void: if Engine.is_editor_hint() or not can_interact: return interacted.emit(self) # World buttons / portals: go immediately (optional short bark via dialog_key) var ui := get_node_or_null("/root/UIRoot") if dialog_key != "" and on_success_goto == "": if ui and ui.has_method("show_dialog_key"): ui.show_dialog_key(dialog_key) elif ui and ui.has_method("show_dialog"): ui.show_dialog(Locale.tr_key(dialog_key), 3.5, dialog_key) if require_item_id != "" and not Inventory.has_item(require_item_id): return if require_item_id != "" and consume_required: Inventory.remove_item(require_item_id, 1) if give_item_id != "": if not (give_once and Inventory.has_item(give_item_id)): Inventory.add_item(give_item_id, 1) if despawn_after_give: call_deferred("queue_free") if quest_flag != "": GameState.set_flag(quest_flag, true) if quest_flag == "house_built": GameState.house_built = true if achievement_id != "": Achievements.unlock(achievement_id) if on_success_goto != "": # Optional one-liner before leave (non-blocking bark if UI supports it) if dialog_key != "" and ui and ui.has_method("show_dialog_key"): ui.show_dialog_key(dialog_key, 1.2, false) GameState.goto_scene(on_success_goto) func get_interact_prompt() -> String: if prompt_key != "": return Locale.tr_key(prompt_key) if texture_path.ends_with("button.png"): return Locale.tr_key("prompt_press_button") if texture_path.ends_with("tiramisu.png"): return Locale.tr_key("prompt_eat_tiramisu") if give_item_id != "": var item_name := Locale.tr_key("item_" + give_item_id) return "%s — %s" % [Locale.tr_key("prompt_pick_up"), item_name] if on_success_goto != "": return Locale.tr_key("prompt_enter") if quest_flag == "house_built": return Locale.tr_key("prompt_build") if dialog_key != "": var label := Locale.tr_key(dialog_key) if label.length() > 42: label = label.substr(0, 40) + "…" return "%s — %s" % [Locale.tr_key("prompt_talk"), label] return Locale.tr_key("prompt_use") func set_interaction_highlight(enabled: bool, color: Color = Color(1.4, 1.35, 0.4)) -> void: if _billboard and _billboard.has_method("set_highlight"): _billboard.set_highlight(enabled, color)