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
221 lines
10 KiB
GDScript
221 lines
10 KiB
GDScript
# netbridge_test —— M2Client 游戏内 API + NetWorld 场景桥的 headless 自检。
|
||
# godot --headless --path project --script netbridge_test.gd
|
||
# 无需服务器:用假 client 驱动 NetWorld;M2Client 部分只查绑定面 + 未入局时拒绝。
|
||
# 退出码 0 = 全过。
|
||
extends SceneTree
|
||
|
||
const NetWorld = preload("res://net_world.gd")
|
||
|
||
class FakeClient extends Node:
|
||
signal entity_spawned(entity: Dictionary)
|
||
signal entity_despawned(vid: int)
|
||
signal entity_moved(vid: int)
|
||
signal entity_main_set(vid: int)
|
||
signal entity_info(vid: int, entity: Dictionary)
|
||
signal chat(type: int, vid: int, text: String)
|
||
signal fly_cue(type: int, start_vid: int, end_vid: int)
|
||
signal fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, append: bool)
|
||
signal fishing_event(subheader: int, info: int, dir: int)
|
||
signal vitals_changed(vid: int)
|
||
signal entity_dead(vid: int)
|
||
signal damage(vid: int, amount: int, flag: int)
|
||
signal pvp_changed(src_vid: int, dst_vid: int, mode: int)
|
||
var ents := {}
|
||
func get_entity(vid: int) -> Dictionary: return ents.get(vid, {})
|
||
func get_entities() -> Array: return ents.values()
|
||
func get_pvp_relations() -> Array: return []
|
||
func spawn(vid, nm, pos, is_main := false):
|
||
ents[vid] = {"vid": vid, "name": nm, "pos": pos, "is_main": is_main,
|
||
"func": 0, "moving": false, "angle_deg": 0.0,
|
||
"hp": 100, "max_hp": 100, "dead": false}
|
||
if is_main: entity_main_set.emit(vid)
|
||
entity_spawned.emit(ents[vid])
|
||
func set_info(vid, nm):
|
||
ents[vid]["name"] = nm
|
||
entity_info.emit(vid, ents[vid])
|
||
func move_to(vid, pos, f := 1):
|
||
ents[vid].merge({"pos": pos, "func": f, "moving": true}, true)
|
||
entity_moved.emit(vid)
|
||
func hurt(vid, hp, amount, flag := 1):
|
||
ents[vid]["hp"] = hp
|
||
ents[vid]["dead"] = hp <= 0
|
||
damage.emit(vid, amount, flag)
|
||
vitals_changed.emit(vid)
|
||
if hp <= 0: entity_dead.emit(vid)
|
||
func despawn(vid):
|
||
ents.erase(vid); entity_despawned.emit(vid)
|
||
func set_fly_target(shooter_vid, target_vid, target_cm, append := false):
|
||
fly_targeting.emit(shooter_vid, target_vid, target_cm, append)
|
||
|
||
var _fail := 0
|
||
func _ck(cond: bool, msg: String) -> void:
|
||
if not cond:
|
||
_fail += 1
|
||
push_error("FAIL: " + msg)
|
||
printerr("FAIL: " + msg)
|
||
|
||
func _init() -> void:
|
||
_test_m2client_surface()
|
||
await _test_netbridge()
|
||
if _fail == 0:
|
||
print("PASS: netbridge_test (M2Client API + NetWorld bridge)")
|
||
quit(0)
|
||
else:
|
||
printerr("%d check(s) failed" % _fail)
|
||
quit(1)
|
||
|
||
func _test_m2client_surface() -> void:
|
||
if not ClassDB.class_exists("M2Client"):
|
||
_ck(false, "M2Client not registered (extension built?)")
|
||
return
|
||
var c: Object = ClassDB.instantiate("M2Client")
|
||
get_root().add_child(c)
|
||
for m in ["move", "character_position", "sync_positions", "request_warp", "fishing", "request_dungeon", "attack", "set_target", "say", "shoot", "add_fly_targeting", "change_name", "download_guild_symbol", "get_guild_symbol", "script_select_item", "quest_cancel", "party_use_skill", "get_skill_group", "get_entity", "get_entities",
|
||
"get_main_vid", "get_points", "get_target", "select_character", "reconnect",
|
||
"suspend", "resume", "move_item", "use_item", "drop_item", "pickup_item",
|
||
"get_inventory", "get_equipment", "get_item", "get_ground_items", "get_pvp_relations", "get_lover", "get_land_areas", "get_observers", "whisper"]:
|
||
_ck(c.has_method(m), "M2Client.%s bound" % m)
|
||
for s in ["entity_spawned", "entity_despawned", "entity_moved", "entity_main_set",
|
||
"chat", "suspended", "resumed", "vitals_changed", "entity_dead", "damage",
|
||
"motion", "points_changed", "target_info", "inventory_changed",
|
||
"ground_item_added", "ground_item_removed", "item_picked_up", "item_used", "whisper_received",
|
||
"entity_info", "warp", "time_changed", "channel_changed", "npc_marks_changed",
|
||
"land_areas_changed", "observer_event", "world_markers_changed", "mount_changed", "pvp_changed", "duel_started", "view_equipment", "lover_changed",
|
||
"fly_targeting", "dig_motion", "fishing_event", "dungeon_event", "skill_group_changed", "guild_make_requested", "guild_symbol_ready"]:
|
||
_ck(c.has_signal(s), "M2Client.%s signal" % s)
|
||
# not in game -> intents refused, snapshot empty
|
||
_ck(c.call("move", 1, 0, 90.0, 1000, 2000) == false, "move refused pre-game")
|
||
_ck(c.call("character_position", 3) == false, "character_position refused pre-game")
|
||
_ck(c.call("sync_positions", [{"vid": 1, "x": 10, "y": 20}]) == false, "sync_positions refused pre-game")
|
||
_ck(c.call("request_warp") == false, "request_warp refused pre-game")
|
||
_ck(c.call("fishing", 90.0) == false, "fishing refused pre-game")
|
||
_ck(c.call("request_dungeon") == false, "request_dungeon refused pre-game")
|
||
_ck(c.call("shoot", 1) == false, "shoot refused pre-game")
|
||
_ck(c.call("script_select_item", 1) == false, "script_select_item refused pre-game")
|
||
_ck(c.call("quest_cancel") == false, "quest_cancel refused pre-game")
|
||
_ck(c.call("party_use_skill", 1, 1) == false, "party_use_skill refused pre-game")
|
||
_ck(c.call("change_name", 0, "Renamed") == false, "change_name refused before connection")
|
||
_ck(c.call("get_skill_group") == 0, "skill group defaults to 0 pre-game")
|
||
_ck(c.call("attack", 0, 1) == false, "attack refused pre-game")
|
||
_ck(c.call("get_entities").is_empty(), "no entities pre-game")
|
||
_ck(c.call("get_main_vid") == 0, "no main vid pre-game")
|
||
_ck(c.call("get_points").is_empty(), "no points pre-game")
|
||
_ck(c.call("get_target").is_empty(), "no target pre-game")
|
||
_ck(c.call("get_inventory").is_empty(), "no inventory pre-game")
|
||
_ck(c.call("move_item", 1, 0, 2, 1, 1) == false, "move_item refused pre-game")
|
||
c.queue_free()
|
||
_test_proto()
|
||
|
||
func _test_proto() -> void:
|
||
_ck(ClassDB.class_exists("Metin2Proto"), "Metin2Proto registered")
|
||
if not ClassDB.class_exists("Metin2Proto"):
|
||
return
|
||
var p: Object = ClassDB.instantiate("Metin2Proto")
|
||
get_root().add_child(p)
|
||
var root := AssetRoot.sub("locale/locale/en")
|
||
if not FileAccess.file_exists(root + "/item_proto"):
|
||
print(" (skip Metin2Proto load: no item_proto)")
|
||
p.queue_free()
|
||
return
|
||
_ck(p.call("load_item_proto", root + "/item_proto"), "load_item_proto: %s" % p.call("get_last_error"))
|
||
_ck(p.call("item_count") > 1000, "item_count > 1000 (%d)" % p.call("item_count"))
|
||
var yang: Dictionary = p.call("item", 1)
|
||
_ck(yang.get("name", "") == "Yang", "item(1).name == Yang")
|
||
_ck(p.call("item", 999999999).is_empty(), "unknown vnum -> {}")
|
||
p.queue_free()
|
||
|
||
func _test_netbridge() -> void:
|
||
var mount := Node3D.new()
|
||
get_root().add_child(mount)
|
||
var fc := FakeClient.new()
|
||
get_root().add_child(fc)
|
||
var nw: Node = NetWorld.new()
|
||
get_root().add_child(nw)
|
||
nw.setup(fc, mount)
|
||
var added := [0]
|
||
var removed := [0]
|
||
var main_node := [null]
|
||
nw.entity_added.connect(func(_n, _v): added[0] += 1)
|
||
nw.entity_removed.connect(func(_v): removed[0] += 1)
|
||
nw.main_entity_ready.connect(func(n, _v): main_node[0] = n)
|
||
|
||
fc.spawn(1000, "Me", Vector3.ZERO, true)
|
||
fc.spawn(2000, "", Vector3(10, 0, 0)) # 名字晚点走 entity_info
|
||
await process_frame
|
||
_ck(added[0] == 2, "2 entity nodes added")
|
||
_ck(mount.get_child_count() == 2, "2 children under mount")
|
||
_ck(main_node[0] != null and int(main_node[0].get_meta("vid")) == 1000, "main entity surfaced")
|
||
fc.set_fly_target(2000, 1000, Vector2(120, 340))
|
||
await process_frame
|
||
_ck(int(nw.node_for(2000).get_meta("fly_target_vid", 0)) == 1000,
|
||
"fly_targeting -> shooter target metadata")
|
||
var projectile_children := mount.get_child_count()
|
||
fc.fly_cue.emit(1, 2000, 0) # coordinate/queued target fallback
|
||
_ck(mount.get_child_count() == projectile_children + 1,
|
||
"create_fly -> consumes queued target when end VID is absent")
|
||
fc.fishing_event.emit(0, 2000, 18)
|
||
await process_frame
|
||
_ck(int(nw.node_for(2000).get_meta("fishing_subheader", -1)) == 0
|
||
and int(nw.node_for(2000).get_meta("fishing_dir", -1)) == 18,
|
||
"fishing start -> entity fishing metadata")
|
||
fc.fishing_event.emit(5, 30001, 0) # item vnum, not a fisher VID
|
||
await process_frame
|
||
_ck(not nw.node_for(2000).has_meta("fishing_item_vnum"),
|
||
"fishing fish outcome is not treated as entity motion")
|
||
|
||
# GC_CHAR_ADD_INFO late name -> 3D label refreshes (name_resolver optional)
|
||
var mob0: Node3D = nw.node_for(2000)
|
||
_ck(String((mob0.get_node("Label3D") as Label3D).text).begins_with("vid "),
|
||
"placeholder label starts as 'vid N' (no name yet)")
|
||
nw.name_resolver = func(d): return "Angry " + String(d.get("name", ""))
|
||
fc.set_info(2000, "Mob")
|
||
await process_frame
|
||
_ck((mob0.get_node("Label3D") as Label3D).text == "Angry Mob",
|
||
"entity_info -> label refreshed via name_resolver")
|
||
fc.pvp_changed.emit(1000, 2000, 2)
|
||
await process_frame
|
||
var pvp_tag := mob0.get_node_or_null("PvpTag") as Label3D
|
||
_ck(pvp_tag != null and pvp_tag.text == "PVP", "pvp fight -> world PVP tag")
|
||
fc.pvp_changed.emit(1000, 2000, 0)
|
||
await process_frame
|
||
_ck(mob0.get_node_or_null("PvpTag") == null, "pvp none -> removes world tag")
|
||
|
||
fc.move_to(2000, Vector3(10, 0, 40))
|
||
for i in 3: await process_frame
|
||
var mob: Node3D = nw.node_for(2000)
|
||
_ck(mob.position.distance_to(Vector3(10, 0, -40)) < 0.5, "far move -> snap") # to_world 翻 Z
|
||
_ck(int(mob.get_meta("func")) == 1, "func -> FUNC_MOVE")
|
||
|
||
fc.move_to(2000, Vector3(11, 0, 41))
|
||
await process_frame
|
||
var d1 := mob.position.distance_to(Vector3(11, 0, -41))
|
||
for i in 20: await process_frame
|
||
_ck(mob.position.distance_to(Vector3(11, 0, -41)) < d1, "near move -> lerp closes in")
|
||
|
||
var got_chat := [""]
|
||
nw.chat_line.connect(func(_t, _v, s): got_chat[0] = s)
|
||
fc.chat.emit(1, 2000, "hi there")
|
||
await process_frame
|
||
_ck(got_chat[0] == "hi there", "chat line relayed")
|
||
|
||
# combat: damage number + vitals + death
|
||
var dmg := [0, false]
|
||
var vit := [0, 0, false]
|
||
nw.damage_number.connect(func(_v, amt, crit, _dodge): dmg[0] = amt; dmg[1] = crit)
|
||
nw.vitals.connect(func(_v, hp, mhp, dead): vit[0] = hp; vit[1] = mhp; vit[2] = dead)
|
||
fc.hurt(2000, 60, 40)
|
||
await process_frame
|
||
_ck(dmg[0] == 40, "damage_number relayed (40)")
|
||
_ck(vit[0] == 60 and vit[1] == 100 and not vit[2], "vitals relayed (60/100)")
|
||
var bar := mob.get_node_or_null("HpBar")
|
||
_ck(bar != null and absf(bar.scale.x - 0.6) < 0.01, "HP bar scaled to 0.6")
|
||
fc.hurt(2000, 0, 60, 1 << 5) # crit, lethal
|
||
await process_frame
|
||
_ck(dmg[1] == true, "crit flag relayed")
|
||
_ck(vit[2] == true, "vitals dead=true")
|
||
|
||
fc.despawn(2000)
|
||
await process_frame
|
||
await process_frame
|
||
_ck(removed[0] == 1 and nw.node_for(2000) == null, "despawn frees node")
|