237 lines
12 KiB
GDScript
237 lines
12 KiB
GDScript
# 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")
|