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
+116
View File
@@ -0,0 +1,116 @@
# chat_test —— P3:聊天窗路由 + 前缀解析 + 气泡 headless 自检。
# godot --headless --path project --script chat_test.gd
extends SceneTree
const ChatUI = preload("res://ui/chat_ui.gd")
const NetWorld = preload("res://net_world.gd")
class FakeClient extends Node:
signal chat(type: int, vid: int, text: String)
# 注:GDScript 不允许 signal 和 func 同名;真 M2Client 是 GDExtension,两者可共存。
# 这里 whisper 用作方法(chat_ui 调 client.whisper(...));入站 whisper 直接调 chat._on_whisper。
signal item_picked_up(vnum: int, count: int, from: String)
signal fishing_event(subheader: int, info: int, dir: int)
# net_world 也连这些:
signal entity_spawned(entity: Dictionary)
signal entity_despawned(vid: int)
signal entity_moved(vid: int)
signal entity_main_set(vid: int)
signal entity_dead(vid: int)
signal vitals_changed(vid: int)
signal damage(vid: int, amount: int, flag: int)
var ents := {1000: {"vid": 1000, "name": "Me"}, 2000: {"vid": 2000, "name": "Bob"}}
var said := []
var whispered := []
func get_entity(vid: int) -> Dictionary: return ents.get(vid, {})
func get_entities() -> Array: return ents.values()
func say(t, s) -> bool: said.append([t, s]); return true
func whisper(to, s) -> bool: whispered.append([to, s]); return true
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
_run()
if _fail == 0:
print("PASS: chat_test (routing + prefixes + bubble)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _last(rt: RichTextLabel) -> String:
return rt.get_parsed_text().strip_edges().split("\n")[-1]
func _run() -> void:
var canvas := CanvasLayer.new()
get_root().add_child(canvas)
var fc := FakeClient.new()
get_root().add_child(fc)
var chat: Node = ChatUI.new()
get_root().add_child(chat)
chat.setup(fc, canvas)
_ck(chat._log.size() == 4, "4 tabs built")
var tAll: RichTextLabel = chat._log[0]
var tWhisper: RichTextLabel = chat._log[1]
var tSys: RichTextLabel = chat._log[2]
var tBattle: RichTextLabel = chat._log[3]
# 普通聊天 -> 全部标签,带名字
fc.chat.emit(0, 2000, "hi all")
_ck(_last(tAll).contains("Bob: hi all"), "talking -> All tab with name")
_ck(chat._buffers[2].size() == 0, "talking not in System tab")
# info -> 系统 + 全部
fc.chat.emit(1, 0, "you leveled up")
_ck(_last(tSys).contains("you leveled up"), "info -> System tab")
_ck(_last(tAll).contains("you leveled up"), "info -> All tab too")
# whisper -> 私聊 + 全部(入站直接调 handler,见 FakeClient 注释)
chat._on_whisper(0, "Alice", "secret")
_ck(_last(tWhisper).contains("Alice: secret"), "whisper -> Whisper tab")
_ck(_last(tAll).contains("Alice: secret"), "whisper -> All tab")
chat._on_whisper(1, "", "server notice")
_ck(_last(tWhisper).contains("[系统]: server notice"), "system whisper -> [系统]")
# 拾取 -> 系统 + 战斗
fc.item_picked_up.emit(27, 3, "")
_ck(_last(tSys).contains("拾取 #27 x3"), "pickup -> System")
_ck(_last(tBattle).contains("拾取 #27 x3"), "pickup -> Battle")
# 钓鱼服务端事件:成功/鱼获进入系统和战斗日志。
fc.fishing_event.emit(3, 1000, 0)
_ck(_last(tSys).contains("鱼钩命中"), "fishing success -> System")
fc.fishing_event.emit(5, 30001, 0)
_ck(_last(tBattle).contains("捕获物品 #30001"), "fishing item -> Battle")
# --- 前缀解析 ---
chat._on_submit("hello world")
_ck(fc.said.size() == 1 and fc.said[0] == [0, "hello world"], "plain -> say(TALKING)")
chat._on_submit("/g guild msg")
_ck(fc.said.size() == 2 and fc.said[1] == [4, "guild msg"], "/g -> say(GUILD)")
chat._on_submit("/s shout!")
_ck(fc.said[2] == [6, "shout!"], "/s -> say(SHOUT)")
chat._on_submit("/w Bob psst hey")
_ck(fc.whispered.size() == 1 and fc.whispered[0] == ["Bob", "psst hey"], "/w Bob ... -> whisper('Bob','psst hey')")
_ck(_last(tWhisper).contains("→ Bob: psst hey"), "/w -> local echo in Whisper tab")
# --- 气泡 ---
var nw: Node = NetWorld.new()
get_root().add_child(nw)
var mount := Node3D.new()
get_root().add_child(mount)
nw.setup(fc, mount)
fc.entity_spawned.emit({"vid": 2000, "name": "Bob", "pos": Vector3.ZERO, "func": 0, "angle_deg": 0.0})
await process_frame
var got_main := [""]
nw.main_bubble.connect(func(t): got_main[0] = t)
nw._bubble(2000, "hi there")
var bob: Node3D = nw.node_for(2000)
_ck(bob != null and bob.get_node_or_null("bubble") != null, "entity 2000 got a bubble node")
nw.set_local_vid(1000)
nw._bubble(1000, "my line")
_ck(got_main[0] == "my line", "local vid bubble -> main_bubble signal")