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
+289
View File
@@ -0,0 +1,289 @@
# p8_test —— P8:组队 / 好友 / 商店 / 交易 / 仓库 UI + 胶水 headless 自检。
# godot --headless --path project --script p8_test.gd
extends SceneTree
const PartyUI = preload("res://ui/party_ui.gd")
const FriendUI = preload("res://ui/friend_ui.gd")
const ShopUI = preload("res://ui/shop_ui.gd")
const ExchangeUI = preload("res://ui/exchange_ui.gd")
const SafeboxUI = preload("res://ui/safebox_ui.gd")
class FakeClient extends Node:
signal party_changed()
signal party_invite_ask(leader_pid: int)
signal friends_changed()
signal shop_opened(vid: int)
signal shop_closed()
signal shop_error(kind: String)
signal exchange_changed()
signal safebox_changed()
var calls := []
var party := []
var friends := []
var shop := []
var shop_tabs := [] # 非空 -> get_shop() 走多货架 (SHOP_GC_START_EX)
var shop_open := false
var exchange := {"active": false}
var safebox := []
var safebox_open := false
var safebox_size := 0
var safebox_gold := 0
var inventory := []
var main_vid := 20
var distribute_mode := 0
func is_in_game() -> bool: return true
func get_main_vid() -> int: return main_vid
func get_party() -> Array: return party
func get_party_distribute_mode() -> int: return distribute_mode
func party_answer(pid, accept) -> bool: calls.append(["party_answer", pid, accept]); return true
func party_invite(vid) -> bool: calls.append(["party_invite", vid]); return true
func party_leave(pid) -> bool: calls.append(["party_leave", pid]); return true
func party_set_state(pid, role, on) -> bool: calls.append(["party_set_state", pid, role, on]); return true
func party_set_distribute(mode) -> bool: calls.append(["party_set_distribute", mode]); return true
func party_use_skill(idx, vid) -> bool: calls.append(["party_use_skill", idx, vid]); return true
func set_target(vid) -> void: calls.append(["set_target", vid])
func get_friends() -> Array: return friends
func add_friend(nm) -> bool: calls.append(["add_friend", nm]); return true
func remove_friend(nm) -> bool: calls.append(["remove_friend", nm]); return true
func get_shop_items() -> Array: return shop
func get_shop() -> Dictionary:
var tabs := shop_tabs if not shop_tabs.is_empty() else [{"name": "", "coin_type": 0, "items": shop}]
return {"vid": 8080, "open": shop_open, "tabs": tabs}
func is_shop_open() -> bool: return shop_open
func shop_buy(pos, cnt) -> bool: calls.append(["shop_buy", pos, cnt]); return true
func shop_sell(cell, cnt) -> bool: calls.append(["shop_sell", cell, cnt]); return true
func shop_close() -> bool: calls.append(["shop_close"]); return true
func get_exchange() -> Dictionary: return exchange
func exchange_add_item(w, c, d) -> bool: calls.append(["ex_add_item", w, c, d]); return true
func exchange_add_gold(g) -> bool: calls.append(["ex_gold", g]); return true
func exchange_accept() -> bool: calls.append(["ex_accept"]); return true
func exchange_cancel() -> bool: calls.append(["ex_cancel"]); return true
func get_safebox_items() -> Array: return safebox
func is_safebox_open() -> bool: return safebox_open
func get_safebox_size() -> int: return safebox_size
func get_safebox_gold() -> int: return safebox_gold
func get_inventory() -> Array: return inventory
func safebox_checkin(sp, w, c) -> bool: calls.append(["sb_checkin", sp, w, c]); return true
func safebox_checkout(sp, w, c) -> bool: calls.append(["sb_checkout", sp, w, c]); return true
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
await _run()
if _fail == 0:
print("PASS: p8_test (party + friends + shop + exchange + safebox)")
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 pu: Node = PartyUI.new()
get_root().add_child(pu)
pu.setup(fc, canvas) # 无 dialogs -> 邀请自动接受
_ck(not pu.is_open(), "party panel hidden when empty")
# 本地玩家 vid=20 是队长 -> 角色分配按钮可用
fc.party = [
{"pid": 2, "vid": 20, "name": "Me", "leader": true, "hp_pct": 90, "state": 1, "affects": [0, 0, 0, 0, 0, 0, 0]},
{"pid": 1, "vid": 10, "name": "Bob", "leader": false, "hp_pct": 50, "state": 0, "affects": [12, 0, 0, 0, 0, 0, 0]},
]
fc.party_changed.emit()
_ck(pu.is_open(), "party panel shows with members")
_ck(pu._list.get_child_count() == 2, "party: 2 member strips")
fc.party_invite_ask.emit(77)
_ck(fc.calls.has(["party_answer", 77, true]), "party: auto-accept invite (no dialogs)")
# 附加효과 칩:Bob 的 affects[0]=12 -> 一个「经验」chip
var bob_strip: Control = pu._list.get_child(1)
var chip_found := false
for l in bob_strip.find_children("*", "Label", true, false):
if String(l.text) == "经验":
chip_found = true
_ck(chip_found, "party: non-zero affect renders a chip")
# 队长点 Me 的 StateButton -> 角色弹窗 -> 「攻击」-> party_set_state(pid, ROLE_ATTACKER, true)
var me_strip: Control = pu._list.get_child(0)
var state_btn: Button = null
for b in me_strip.find_children("*", "Button", true, false):
if String(b.text) in ["", "", "", "", "", "", "", ""]:
state_btn = b
break
_ck(state_btn != null and not state_btn.disabled, "party: leader's StateButton enabled")
state_btn.pressed.emit()
_ck(is_instance_valid(pu._role_popup), "party: role popup opens for leader")
var atk_btn: Button = null
var expel_btn: Button = null
for b in pu._role_popup.find_children("*", "Button", true, false):
if String(b.text).begins_with("攻击"): atk_btn = b
if String(b.text) == "踢出队伍": expel_btn = b
atk_btn.pressed.emit()
_ck(fc.calls.has(["party_set_state", 2, 2, true]),
"party: 攻击 -> party_set_state(pid, ATTACKER=2, true)")
# 다시 열어 踢出
state_btn.pressed.emit()
for b in pu._role_popup.find_children("*", "Button", true, false):
if String(b.text) == "踢出队伍": expel_btn = b
expel_btn.pressed.emit()
_ck(fc.calls.has(["party_leave", 2]), "party: 踢出队伍 -> party_leave(pid)")
# EXP 分配切换(当前 mode 0 -> 发 1
pu._toggle_distribute()
_ck(fc.calls.has(["party_set_distribute", 1]), "party: 分配切换 -> party_set_distribute(PARITY)")
# 组队治疗
pu._party_heal()
_ck(fc.calls.has(["party_use_skill", 1, 0]), "party: 组队治疗 -> party_use_skill(HEAL=1, 0)")
# 点名字 -> 选中该员 vid
var name_btn: Button = null
for b in me_strip.find_children("*", "Button", true, false):
if String(b.text).ends_with("Me"): name_btn = b
if name_btn:
name_btn.pressed.emit()
_ck(fc.calls.has(["set_target", 20]), "party: 点名字 -> set_target(vid)")
# 非队长 -> StateButton disabled
fc.party[0]["leader"] = false
fc.party[0]["state"] = 0
fc.party_changed.emit()
var me2: Control = pu._list.get_child(0)
for b in me2.find_children("*", "Button", true, false):
if String(b.text) in ["", ""]:
_ck(b.disabled, "party: non-leader StateButton disabled")
break
# --- 好友 ---
var fu: Node = FriendUI.new()
get_root().add_child(fu)
fu.setup(fc, canvas)
fu.toggle()
_ck(fu.is_open(), "friend window opens")
fc.friends = [{"name": "Alice", "online": true}, {"name": "Bob", "online": false}]
fc.friends_changed.emit()
await process_frame
var wt := []
fu.whisper_to.connect(func(n): wt.append(n))
# 找 Alice 行的名字按钮
var alice_btn: Button = null
for b in fu._list.find_children("*", "Button", true, false):
if (b as Button).text == "Alice":
alice_btn = b
_ck(alice_btn != null, "friend: Alice row present")
if alice_btn:
alice_btn.pressed.emit()
_ck(wt == ["Alice"], "friend: click name -> whisper_to(Alice)")
fu._name_edit.text = "Carol"
fu._on_add()
_ck(fc.calls.has(["add_friend", "Carol"]), "friend: add via input")
# --- 商店 ---
var su: Node = ShopUI.new()
get_root().add_child(su)
su.setup(fc, canvas)
fc.shop = [{"pos": 0, "vnum": 27, "price": 1500, "count": 1},
{"pos": 1, "vnum": 19, "price": 800, "count": 1}]
fc.shop_opened.emit(8080)
_ck(su.is_open(), "shop opens on shop_opened")
_ck(su._list.get_child_count() == 2, "shop: 2 item rows")
# 第一行的「买」按钮
var buy_btn: Button = null
for b in su._list.get_child(0).find_children("*", "Button", true, false):
buy_btn = b
if buy_btn:
buy_btn.pressed.emit()
_ck(fc.calls.has(["shop_buy", 0, 1]), "shop: buy row 0")
su.sell(5)
_ck(fc.calls.has(["shop_sell", 5, 1]), "shop: sell inv cell 5")
fc.shop_error.emit("NOT_ENOUGH_MONEY")
_ck(su._err.text == "金币不足", "shop: error text localised")
fc.shop_closed.emit()
_ck(not su.is_open(), "shop closes on shop_closed")
# --- 商店 SHOP_GC_START_EX(多货架 + tabIdx*40+slot 买位置) ---
fc.calls.clear()
fc.shop_tabs = [
{"name": "武器", "coin_type": 0, "items": [{"pos": 0, "vnum": 101, "price": 500, "count": 1},
{"pos": 5, "vnum": 202, "price": 900, "count": 3}]},
{"name": "防具", "coin_type": 1, "items": [{"pos": 3, "vnum": 303, "price": 12000, "count": 1}]},
]
fc.shop_opened.emit(8080)
_ck(su.is_open(), "shop_ex: opens")
_ck(su._tabbar.visible and su._tabbar.get_child_count() == 2, "shop_ex: 2 tab buttons")
_ck(su._list.get_child_count() == 2, "shop_ex: tab 0 has 2 rows")
# tab 0 第二行(slot 5)买 -> pos = 0*40 + 5
var r1_buy: Button = null
for b in su._list.get_child(1).find_children("*", "Button", true, false):
r1_buy = b
if r1_buy:
r1_buy.pressed.emit()
_ck(fc.calls.has(["shop_buy", 5, 1]), "shop_ex: tab0 slot5 -> buy pos 5")
# 切到货架 2
su._tabbar.get_child(1).pressed.emit()
_ck(su._active_tab == 1, "shop_ex: switched to tab 1")
_ck(su._list.get_child_count() == 1, "shop_ex: tab 1 has 1 row")
var t1_buy: Button = null
for b in su._list.get_child(0).find_children("*", "Button", true, false):
t1_buy = b
if t1_buy:
t1_buy.pressed.emit()
_ck(fc.calls.has(["shop_buy", 43, 1]), "shop_ex: tab1 slot3 -> buy pos 1*40+3=43")
fc.shop_closed.emit()
fc.shop_tabs = []
# --- 交易 ---
var xu: Node = ExchangeUI.new()
get_root().add_child(xu)
xu.setup(fc, canvas)
fc.exchange = {
"active": true, "partner_vid": 4321,
"self_items": [{"slot": 0, "vnum": 19, "count": 1}],
"peer_items": [], "self_gold": 100, "peer_gold": 0,
"self_accept": false, "peer_accept": true,
}
fc.exchange_changed.emit()
_ck(xu.is_open(), "exchange window opens when active")
_ck(xu._self_box.get_child_count() == 1, "exchange: 1 self item row")
_ck(xu._root.get_node("PeerAccept").text == "对方: 已接受", "exchange: peer accept shown")
xu.offer(1, 7)
_ck(fc.calls.has(["ex_add_item", 1, 7, 0]), "exchange: offer item (display 0)")
xu._gold_input.text = "500"
xu._on_put_gold()
_ck(fc.calls.has(["ex_gold", 500]), "exchange: put gold")
fc.exchange = {"active": false}
fc.exchange_changed.emit()
_ck(not xu.is_open(), "exchange closes when inactive")
# --- 仓库 ---
var bu: Node = SafeboxUI.new()
get_root().add_child(bu)
bu.setup(fc, canvas)
fc.safebox_open = true
fc.safebox_size = 2
fc.safebox_gold = 99999
fc.safebox = [{"cell": 3, "vnum": 27, "count": 10}]
fc.safebox_changed.emit()
_ck(bu.is_open(), "safebox opens")
_ck(bu._gold.text.contains("99999"), "safebox: gold shown")
_ck(bu._list.get_child_count() == 1, "safebox: 1 item row")
var out_btn: Button = null
for b in bu._list.get_child(0).find_children("*", "Button", true, false):
out_btn = b
if out_btn:
out_btn.pressed.emit()
_ck(fc.calls.any(func(c): return c[0] == "sb_checkout" and c[1] == 3), "safebox: checkout cell 3")
fc.inventory = [{"cell": 0}, {"cell": 1}]
bu.deposit(1, 4)
# 下一个空仓位应是 0(cell 3 已用)
_ck(fc.calls.any(func(c): return c[0] == "sb_checkin" and c[2] == 1 and c[3] == 4),
"safebox: deposit inv cell 4")