# quest_test —— P7:GC_SCRIPT 渲染 + quest_confirm + quest_log + NPC 点击 headless 自检。 # godot --headless --path project --script quest_test.gd extends SceneTree const QuestDialog = preload("res://ui/quest_dialog.gd") const QuestLog = preload("res://ui/quest_log.gd") class FakeClient extends Node: signal script_dialog(skin: int, text: String) signal quest_confirm_ask(msg: String, timeout: int, request_pid: int) signal quest_info(index: int) var answers := [] var cancels := 0 var inputs := [] var confirms := [] var clicks := [] var quests := [] var script_buttons := [] func is_in_game() -> bool: return true func get_quests() -> Array: return quests func script_answer(a) -> bool: answers.append(a); return true func quest_cancel() -> bool: cancels += 1; return true func quest_input(text) -> bool: inputs.append(text); return true func quest_confirm(yes, pid) -> bool: confirms.append([yes, pid]); return true func script_button(index) -> bool: script_buttons.append(index); return true func click_npc(vid) -> bool: clicks.append(vid); return true func get_entity(_v) -> Dictionary: return {} var _fail := 0 func _ck(c: bool, m: String) -> void: if not c: _fail += 1 printerr("FAIL: " + m) func _init() -> void: _run() if _fail == 0: print("PASS: quest_test (script parse + dialog + confirm + log)") quit(0) else: printerr("%d check(s) failed" % _fail) quit(1) func _run() -> void: var canvas := CanvasLayer.new() get_root().add_child(canvas) var fc := FakeClient.new() get_root().add_child(fc) # --- 脚本解析 --- var qd: Node = QuestDialog.new() get_root().add_child(qd) qd.setup(fc, canvas) var p: Dictionary = qd.parse_script("Greetings, hero.[ENTER]The village needs you.[ENTER][QUESTION arg(\"I accept\") arg(\"Not now\")]") _ck(p.body.contains("Greetings, hero.") and p.body.contains("village needs"), "plain text kept") _ck(p.body.split("\n").size() == 2, "[ENTER] -> line break (%d lines)" % p.body.split("\n").size()) _ck(p.choices == ["I accept", "Not now"], "[QUESTION] -> 2 choices") var p2: Dictionary = qd.parse_script("Bye.[DONE]") _ck(p2.has_next and p2.choices.is_empty(), "[DONE] -> continue button, no choices") var p3: Dictionary = qd.parse_script("[COLOR256 value;255 value;128 value;0]Gold[ENTER][COLOR value;0 value;1 value;0]Green[ITEM value;19][MOB value;77][WINDOW_SIZE value;640 value;360][TEXT_HORIZONTAL_ALIGN_CENTER][CONFIRM_WAIT value;30]") _ck(p3.body.contains("[color=#ff8000]") and p3.body.contains("Gold") and p3.body.contains("[color=#00ff00]"), "COLOR/COLOR256 -> RichTextLabel color spans") _ck(p3.body.contains("物品 #19") and p3.body.contains("怪物 #77"), "ITEM/MOB -> named placeholders") _ck(p3.window_size == [640, 360] and p3.text_centered and p3.confirm_wait and p3.confirm_timeout == 30, "WINDOW_SIZE/TEXT_CENTER/CONFIRM_WAIT metadata") var p4: Dictionary = qd.parse_script("[QUESTBUTTON idx(12) name(\"Read me\") icon_type(\"file\") icon_name(\"quest.sub\")][ADDMAPSIGNAL x(1200) y(3400)][SETCMAPPOS x(8) y(-4)][CLEARMAPSIGNAL][SET_CAMERA value(1) value(2)][FADE_OUT speed(0.25)]") _ck(p4.quest_buttons.size() == 1 and p4.quest_buttons[0].index == 12 and p4.quest_buttons[0].title == "Read me", "QUESTBUTTON metadata") _ck(p4.map_signals.size() == 1 and p4.map_signals[0] == Vector2(1200, 3400) and p4.clear_map_signals and p4.atlas_center == [8, -4], "map signal metadata") _ck(p4.camera_events.size() == 1 and p4.fade_events.size() == 1 and p4.fade_events[0].kind == "FADE_OUT", "camera/fade metadata") # --- 对话弹窗 + 选择 -> script_answer --- fc.script_dialog.emit(1, "Pick one.[QUESTION arg(\"A\") arg(\"B\") arg(\"C\")]") await process_frame _ck(qd.is_open(), "dialog shown on script_dialog") _ck(qd._btnrow.get_child_count() == 3, "3 choice buttons") (qd._btnrow.get_child(1) as Button).pressed.emit() # 选 B (index 1) _ck(fc.answers == [1], "choice B -> script_answer(1)") _ck(not qd.is_open(), "dialog closes after choice") # 无选项 -> 「继续」-> script_answer(255) fc.script_dialog.emit(0, "Just text.[NEXT]") await process_frame _ck(qd._btnrow.get_child_count() == 1, "1 continue button") (qd._btnrow.get_child(0) as Button).pressed.emit() _ck(fc.answers == [1, 255], "continue -> script_answer(255)") # DONE is a button action in ClientVS22. Parsing / receiving the token must # not fire DoneEvent before the player confirms the close button. var done_events := 0 qd.done_event.connect(func(): done_events += 1) var done_parse: Dictionary = qd.parse_script("Finished.[DONE]") _ck(done_parse.next_button_type == "DONE" and done_events == 0, "DONE parse stays side-effect free") fc.script_dialog.emit(0, "Finished.[DONE]") await process_frame (qd._btnrow.get_child(0) as Button).pressed.emit() _ck(done_events == 1 and fc.answers == [1, 255, 255], "DONE click -> DoneEvent + script_answer(255)") # ESC dismisses the active script through the legacy QUEST_CANCEL packet. fc.script_dialog.emit(0, "Cancelable quest.[NEXT]") await process_frame var esc := InputEventKey.new() esc.pressed = true esc.keycode = KEY_ESCAPE qd._unhandled_input(esc) _ck(fc.cancels == 1, "ESC -> quest_cancel()") _ck(not qd.is_open(), "ESC closes quest dialog") # [INPUT] renders a LineEdit and sends the entered text through QUEST_INPUT_STRING. var pi: Dictionary = qd.parse_script("Tell me your name.[INPUT]") _ck(pi.has_input, "[INPUT] parsed") fc.script_dialog.emit(0, "Tell me your name.[INPUT]") await process_frame var input := qd._btnrow.get_child(0) as LineEdit input.text = "Alice" (qd._btnrow.get_child(1) as Button).pressed.emit() _ck(fc.inputs == ["Alice"], "[INPUT] submit -> quest_input('Alice')") _ck(not qd.is_open(), "input submit closes quest dialog") # EventManager side effects are surfaced to the interface owner. var received := [] var cleared := [] var signal_points := [] qd.quest_button_received.connect(func(index, title, icon_type, icon_name): received.append([index, title, icon_type, icon_name])) qd.quest_button_cleared.connect(func(index): cleared.append(index)) qd.map_signal_added.connect(func(x, y): signal_points.append(Vector2(x, y))) fc.script_dialog.emit(0, "[QUESTBUTTON idx(12) name(\"Read me\") icon_type(\"file\") icon_name(\"quest.sub\")][QUESTBUTTON_CLOSE idx(8)][ADDMAPSIGNAL x(1200) y(3400)][CLEARMAPSIGNAL]") await process_frame _ck(received == [[12, "Read me", "file", "quest.sub"]] and cleared == [8], "QUESTBUTTON events reach interface") _ck(signal_points == [Vector2(1200, 3400)], "ADDMAPSIGNAL reaches minimap owner") # --- quest_confirm --- fc.quest_confirm_ask.emit("Join the party?", 30, 999) await process_frame _ck(qd.is_open(), "confirm shown") # 找「接受」按钮 var acc: Button = null for n in qd._btnrow.find_children("*", "Button", true, false): if n.text == "接受": acc = n _ck(acc != null, "accept button present") if acc: acc.pressed.emit() _ck(fc.confirms == [[true, 999]], "accept -> quest_confirm(true, 999)") # --- quest_log --- var ql: Node = QuestLog.new() get_root().add_child(ql) ql.setup(fc, canvas) qd.quest_button_received.connect(func(index, title, icon_type, icon_name): ql.recv_quest(index, title, icon_type, icon_name)) qd.quest_button_cleared.connect(ql.clear_quest) fc.quests = [ {"index": 1, "title": "Slay 3 wolves", "counter_name": "Wolves", "counter_value": 2, "clock_name": "Time", "clock_value": 65, "icon": "missing/icon.sub"}, {"index": 2, "title": "", "counter_name": "", "counter_value": 0, "clock_name": "", "clock_value": 0}, # 完成 -> 不显示 ] ql.toggle() _ck(ql.is_open(), "quest log opened") await process_frame # 只 1 条有标题的 var titles := 0 for box in ql._list.get_children(): for lbl in box.find_children("*", "Label", true, false) if box is VBoxContainer else []: if String(lbl.text).begins_with("◆"): titles += 1 _ck(titles == 1, "quest log shows 1 titled quest (got %d)" % titles) _ck(ql._clock_labels.has(1) and String(ql._clock_labels[1].text).contains("1分5秒"), "quest clock renders minutes and seconds") # ClientVS22 only renders QUEST_MAX_NUM=5 rows and maps the scrollbar to a # discrete questShowingStartIndex. Empty icon paths use the default scroll icon. fc.quests.clear() for i in range(7): fc.quests.append({"index": 100 + i, "title": "Quest %d" % i, "counter_name": "", "counter_value": 0, "clock_name": "", "clock_value": 0, "icon": ""}) ql.refresh() await process_frame _ck(ql.quest_count() == 7 and ql._quest_scrollbar.visible, "quest log exposes all quests and scroll bar") _ck(ql.visible_quest_indices() == [0, 1, 2, 3, 4], "quest log initially shows 5 rows") ql.set_scroll_ratio(1.0) await process_frame _ck(ql.quest_start_index() == 2 and ql.visible_quest_indices() == [2, 3, 4, 5, 6], "quest scroll maps to last 5 rows") ql.select_quest(6) _ck(fc.script_buttons == [4, -2147483648 + 106], "quest row selection -> event quest button packet") fc.script_dialog.emit(0, "[QUESTBUTTON idx(4) name(\"Talk to guard\") icon_type(\"highlight\") icon_name(\"\")]") await process_frame _ck(ql.quest_button_count() == 1 and ql._button_root.visible, "quest button strip receives QUESTBUTTON") (ql._button_list.get_child(0) as Button).pressed.emit() _ck(fc.script_buttons == [4] and ql.quest_button_count() == 0, "quest button click -> script_button and remove") fc.quest_info.emit(1) # 刷新不炸 await process_frame _ck(true, "quest_info refresh ok") # --- 任务信件按钮条:多列定位(1:1 interfacemodule.__ArrangeQuestButton,§5.2 增量 109)--- # 参考公式:yPos = 170*H//600 ; yCount = (H-330)//63 ; 第 i 个 = # (xPos + (i//yCount)*100 , yPos + (i%yCount)*63) ; xPos = 20 / 130(组队窗显示) _ck(QuestLog.quest_button_y_count(600.0) == 4, "quest btn: yCount(600) == 4") _ck(QuestLog.quest_button_y_count(1080.0) == 11, "quest btn: yCount(1080) == 11") _ck(QuestLog.quest_button_y_count(350.0) == 1, "quest btn: yCount clamps to 1 below ZeroDivision") _ck(QuestLog.quest_button_y_pos(600.0) == 170, "quest btn: yPos(600) == 170") _ck(QuestLog.quest_button_y_pos(1200.0) == 340, "quest btn: yPos scales with height") ql.set_screen_size_override(Vector2(800, 600)) for i in range(6): ql.recv_quest(10 + i, "Letter %d" % i, "file", "") await process_frame # insert(0, btn):最新在前 _ck(ql.quest_button_order() == [15, 14, 13, 12, 11, 10], "quest btn: newest inserted at front") _ck(ql.quest_button_position(15) == Vector2(20, 170), "quest btn: slot 0 at (xPos, yPos)") _ck(ql.quest_button_position(12) == Vector2(20, 359), "quest btn: slot 3 stacks down one column") _ck(ql.quest_button_position(11) == Vector2(120, 170), "quest btn: slot 4 wraps to column 2") _ck(ql.quest_button_position(10) == Vector2(120, 233), "quest btn: slot 5 is column 2 row 1") _ck(ql._button_list.get_child(0).position == Vector2(20, 170), "quest btn: node position matches formula") # 同 index 再来 -> 移到最前 ql.recv_quest(13, "Letter 3 (again)", "file", "") await process_frame _ck(ql.quest_button_order() == [13, 15, 14, 12, 11, 10], "quest btn: re-received index moves to front") # 组队窗显示 -> xPos 130 ql.set_party_shown(true) await process_frame _ck(ql.quest_button_position(13) == Vector2(130, 170), "quest btn: party window shifts xPos to 130") _ck(ql._button_list.get_child(0).position == Vector2(130, 170), "quest btn: node repositioned on party toggle") ql.set_party_shown(false) ql.clear_quest(13) await process_frame _ck(not ql.quest_button_order().has(13) and ql.quest_button_count() == 5, "quest btn: clear drops index from order") for i in range(6): ql.clear_quest(10 + i) await process_frame _ck(ql.quest_button_order().is_empty() and not ql._button_root.visible, "quest btn: emptied strip hides root")