Metin2 game client (P0–P11) + mobile asset pipeline

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
This commit is contained in:
shen
2026-08-31 20:02:12 +09:00
co-authored by Claude Sonnet 5
parent f4917a2b3b
commit 47baf6c0c6
414 changed files with 69568 additions and 385 deletions
+131
View File
@@ -0,0 +1,131 @@
# quest_test —— P7GC_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")