Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
phases, EntityStore world model, ~all GC/CG headers. char create/delete,
private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
token), system-option + game-option + ESC system menu, private-shop 39-grid,
party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.
Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.
Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).
ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
132 lines
4.9 KiB
GDScript
132 lines
4.9 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 := []
|
||
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 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")
|
||
|
||
# --- 对话弹窗 + 选择 -> 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)")
|
||
|
||
# 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")
|
||
|
||
# --- 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)
|
||
fc.quests = [
|
||
{"index": 1, "title": "Slay 3 wolves", "counter_name": "Wolves", "counter_value": 2, "clock_name": "", "clock_value": 0},
|
||
{"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)
|
||
fc.quest_info.emit(1) # 刷新不炸
|
||
await process_frame
|
||
_ck(true, "quest_info refresh ok")
|