Files
mtgodot-poc/project/p8_test.gd
T
shenandClaude Sonnet 5 47baf6c0c6 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
2026-08-31 20:02:12 +09:00

290 lines
12 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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")