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