Files
mtgodot-poc/project/netbridge_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

221 lines
10 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.
# netbridge_test —— M2Client 游戏内 API + NetWorld 场景桥的 headless 自检。
# godot --headless --path project --script netbridge_test.gd
# 无需服务器:用假 client 驱动 NetWorldM2Client 部分只查绑定面 + 未入局时拒绝。
# 退出码 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")